Skip to main content

brink_analyzer/
strict.rs

1//! TM-3 strict typed-mode policy (docs/typed-mode-spec.md §1/§4/§5/§9-step-3).
2//!
3//! `types = strict` is a project-level config option, orthogonal to (but
4//! gated by) the T1b dialect (`docs/t1b-surface-spec.md` §1): strict typing
5//! requires the brink dialect, since its annotation syntax (TM-2, spec §3)
6//! is brink-extension syntax. Three jobs live here:
7//!
8//! - [`config_error`]: `types = strict` + `dialect = strict-ink` is a
9//!   project-level config error (`E064`), reported once and skipping every
10//!   other strict-mode check (there is nothing more useful to say about a
11//!   project whose dialect already rejects the annotation syntax strict mode
12//!   needs).
13//! - [`check`]: the inference-driven strict diagnostics — Unknown-escape
14//!   (`E065`) and Conflicted-escape (`E066`) over every inferable def's
15//!   signature and body-local slots (spec §1: "Unknown escaping inference is
16//!   a compile error"; the #627-landed `Ty::Conflicted` absorbing point is
17//!   strict mode's payoff, spec's own words: "TM-3 (#619) is the slice that
18//!   turns a Conflicted slot into a real strict-mode error"), the void-
19//!   assignment check (`E067`, spec §3: "assigning a `void` call is an error
20//!   in strict mode" — a `~ x = f()` / `~ temp x = f()` whose RHS *root* is a
21//!   call resolving to a `void`-returning function; statement-position calls
22//!   and calls nested in interpolation are never flagged), plus wiring the
23//!   already-landed advisory `annotations::mismatches` (`E063`) into
24//!   production under strict (the inherited #640-round ruling: "TM-3's
25//!   strict-policy wiring, which must run inference anyway, is where E063
26//!   starts firing in production").
27//! - [`effective_severity`]: the policy-conditional severity lookup both of
28//!   `brink-db`'s diagnostic-partitioning sites (`partition_diagnostics`'s
29//!   two call sites, plus `lir_query`'s own LIR-diagnostic partition) must
30//!   call instead of the raw [`brink_ir::DiagnosticCode::severity`] default —
31//!   `E063` is `Warning` under `types = gradual` but `Error`-eligible under
32//!   `types = strict` (the #640-round ruling this module's `check` doc above
33//!   already cites); every other code's severity is policy-independent.
34//!
35//! A slot is exempted from Unknown-escape when an explicit, resolvable type
36//! annotation is present (TM-2's "annotation = firewall" — the entire point
37//! of annotating a boundary is to supply the concrete type inference alone
38//! couldn't pin down, spec §5's own worked example: `#[]` is an `Unknown`
39//! escape *unless* the binding is annotated). A `Conflicted` slot is never
40//! exempted by an annotation — the body's own uses genuinely disagree with
41//! each other, which no annotation can resolve (`annotations::mismatches`
42//! already declines to compare against a `Conflicted`/`Unknown` body type
43//! for the same reason, via [`Ty::is_unresolved`]).
44//!
45//! Coercion lattice (spec §4) and collection-literal joins (spec §5) need no
46//! separate enforcement pass here: `infer::ty::unify` already implements the
47//! lattice (`int -> float` directional, everything else structurally
48//! mismatched joins to `Conflicted`), condition positions are already
49//! inferred without forcing `bool` (`infer::body`'s module doc — the
50//! int-truthiness idiom `{visited_knot: ...}` types as a clean concrete
51//! `int`, never escapes), and a heterogeneous collection literal
52//! (`#[1, "a"]`) already comes out `Array(Conflicted)` — this module's
53//! recursive [`classify`] walk catches it precisely because it *is* the same
54//! lattice, not a parallel implementation of it.
55//!
56//! ## Scope (see PR description for the full list)
57//!
58//! This slice does **not** implement: the boundary-annotation-*required*
59//! diagnostic (spec's "host-callable functions... and entry points require
60//! explicit annotations" has no ratified, mechanically-checkable definition
61//! of either term in the codebase today — inventing one here would be
62//! unilateral architecture, not wiring). The `int()`/`float()`/`string()`
63//! pure conversion intrinsics (TM-3 completion, issue #659) now exist —
64//! VM-native ops plus the `conversions` module's strict-mode domain check,
65//! wired in below alongside `structs::check`.
66//!
67//! Issue #1877 closed a gap this doc used to describe as out of scope:
68//! `VAR`/`CONST` cross-type-reassignment detection. `infer::body`'s
69//! `observe` still only accumulates for `Param`/`Temp` locals into the
70//! `Ty::Conflicted` lattice — that much is unchanged — but a global
71//! assignment target's already-known declaration-derived type
72//! (`BodyCtx::globals`) is now checked directly against the RHS's inferred
73//! type ([`check_typed_assign_mismatches`], `E063`), independently of that
74//! lattice. The same PR added [`check_global_initializers`] for the sibling
75//! declaration-initializer gap (a VAR/CONST's own explicit annotation
76//! disagreeing with its initializer literal) and a `~ temp` initializer's
77//! ascription check (`infer::body::InferPass::check_declared_temp_init`).
78
79use std::collections::{BTreeMap, BTreeSet};
80
81use brink_format::DefinitionId;
82use brink_ir::{
83    Block, BlockStmt, Content, ContentPart, ElseBranch, Expr, FileId, HirFile, IfStmt, Path,
84    ResolutionMap, Stmt, SymbolIndex, SymbolKind, TypeExpr,
85};
86use rowan::TextRange;
87
88use crate::annotations;
89use crate::infer::{InferenceResult, InferredSig, Ty};
90
91// `TypePolicy` is defined in `brink-project-config` alongside `Dialect` —
92// both are project-policy types the analyzer consumes rather than owns, and
93// keeping them there is what lets that crate publish standalone (#1234).
94// Re-exported so every existing `brink_analyzer::TypePolicy` path is
95// unchanged. The *default* remains dialect-keyed via `resolve_type_policy`
96// below (issue #1127); the derived `Default` (`Gradual`) exists only so
97// pre-resolution containers can derive theirs and must never be read as the
98// policy default.
99pub use brink_project_config::TypePolicy;
100
101// `LintLevel` is defined in `brink-project-config` for the same reason as
102// `TypePolicy` above (#1234). Re-exported so `brink_analyzer::LintLevel` is
103// the canonical path every consumer of [`LintPolicy::overrides`] uses.
104pub use brink_project_config::LintLevel;
105
106/// The resolved `[lints]` policy (issue #1160): per-code severity overrides
107/// plus the blanket `deny-warnings` flag. Bundled as its own small,
108/// cheaply-`PartialEq`-comparable value — rather than as two loose scalars —
109/// so `brink-db`'s severity-partitioning call sites can share one narrow
110/// salsa projection the same way [`TypePolicy`] already does (see
111/// `brink-db`'s `type_policy_query`/`lint_policy_query` doc comments for the
112/// cutoff argument).
113///
114/// This is the `AnalysisOptions::lints` field's type — resolved once, at
115/// `Project::load` (via `AnalysisOptions::apply_project_config`), never
116/// re-derived at a call site (#1160's "apply it at the ONE point" mandate).
117#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
118pub struct LintPolicy {
119    /// Per-code overrides, keyed by the diagnostic code's string form
120    /// (`DiagnosticCode::as_str`, e.g. `"E063"`). Only ever consulted for
121    /// codes whose *default* severity ([`brink_ir::DiagnosticCode::severity`])
122    /// is `Warning` — see [`effective_severity`]'s doc comment for why a
123    /// hard-error-by-default code is never even looked up here.
124    pub overrides: BTreeMap<String, LintLevel>,
125    /// `[lints] deny-warnings = true`: promote every diagnostic that would
126    /// otherwise resolve to `Warning` up to `Error` (the `-D warnings`
127    /// equivalent). A code with an explicit [`Self::overrides`] entry is
128    /// unaffected by this flag — `Deny` is `Error` either way, and `Allow`
129    /// is specifically the "stay `Warning` even under `deny-warnings`" knob.
130    pub deny_warnings: bool,
131}
132
133/// THE `types`-default resolution function (issue #1127, decision-log
134/// 2026-07-19 "Typing posture ruled"). An explicit `types = …` — a CLI
135/// `--types` flag, a `brink.toml` `[project] types` key, an editor/LSP API
136/// call — always wins. When the project never says, the default is keyed on
137/// the dialect:
138///
139/// - `Dialect::Brink` → **`Strict`** (the flip: the new surfaces are
140///   designed under the strict doctrine; gradual remains an opt-out knob);
141/// - `Dialect::StrictInk` → **`Gradual`**, forever (the oracle corpus is
142///   anchored to it — byte-identity preserved by construction).
143///
144/// Every mount resolves through this one function (usually via
145/// `AnalysisOptions::type_policy`); no other code may invent a `types`
146/// default.
147#[must_use]
148pub fn resolve_type_policy(dialect: crate::Dialect, explicit: Option<TypePolicy>) -> TypePolicy {
149    explicit.unwrap_or(match dialect {
150        crate::Dialect::Brink => TypePolicy::Strict,
151        crate::Dialect::StrictInk => TypePolicy::Gradual,
152    })
153}
154
155/// The severity a diagnostic code should actually be reported at, given the
156/// project's `types` policy and resolved `[lints]` policy — the single seam
157/// every diagnostic-partitioning site must call instead of the raw
158/// [`brink_ir::DiagnosticCode::severity`] default.
159///
160/// Resolution order:
161///
162/// 1. **Type-policy carve-out** (the #640-round ruling: "TM-3's
163///    strict-policy wiring, which must run inference anyway, is where E063
164///    starts firing in production") — `E063` (annotation-vs-inference
165///    mismatch) is `Warning` under `types = gradual` but `Error` under
166///    `types = strict`. Every other code's *base* severity is
167///    policy-independent and comes straight from
168///    [`brink_ir::DiagnosticCode::severity`].
169/// 2. **Hard-error exemption** (issue #1160): if the base severity is
170///    already `Error` AND the code is not a **compat-deny** tier member
171///    (issue #3373, RULED 2026-09-01 — [`brink_ir::DiagnosticCode::is_compat_deny`]),
172///    `lints` is never consulted — a code that is a hard error by default
173///    can never be downgraded by `[lints]`. This is the "conservative
174///    overridable set" #1160 asks for: rather than inventing a policy for
175///    which `Error`-default codes are "safe" to relax, none of them are
176///    reachable through this table at all. Everything else (a `Warning`-base
177///    code, an `Info`/`Hint`-base one since issue #1674, or an
178///    `Error`-base **compat-deny** member since issue #3373) *is* reachable;
179///    the exemption is specifically about an `Error`-default code that is
180///    NOT a compat-deny member.
181/// 3. **`[lints]` per-code override**: `Deny` → `Error`; `Allow` → `None`,
182///    the diagnostic is SUPPRESSED and callers must drop it (#3173);
183///    `Info`/`Hint` (issue #1162) →
184///    `Severity::Info`/`Severity::Hint`, also immune to step 4 — an author
185///    who deliberately down-leveled a code to an advisory tier does not want
186///    `deny-warnings` escalating it back past `Warning`, the same reasoning
187///    `Allow`'s immunity already rests on; `Warn` → `Severity::Warning`
188///    unconditionally (an explicit ask to promote an `Info`/`Hint`-base code
189///    up a tier, or to restate a `Warning`-base code's own default).
190/// 4. **`deny-warnings`**: *no* override, resolving to the base severity —
191///    becomes `Error` if that base is `Warning` and `lints.deny_warnings` is
192///    set (the `-D warnings` equivalent); an `Info`/`Hint`-base code with no
193///    override is never touched by `deny-warnings` (issue #1674: the
194///    default-`Info` `E157`'s whole point is staying quiet until an author
195///    opts it up through `[lints]` — `deny-warnings` alone must not do that
196///    for them).
197///
198/// Returns `None` when `[lints]` sets the code to `allow` — the diagnostic
199/// is suppressed and the caller must not report it.
200///
201/// The `Option` is the point (#3173). Until it existed this returned the
202/// code's BASE severity for `allow`, because `Severity` has no suppressed
203/// variant — so `allow` silently did nothing at all, in the CLI, the LSP
204/// and the studio alike, and it is the only level that turns a diagnostic
205/// off. A predicate beside this function would have fixed the symptom
206/// while leaving the cause: that a consumer can ask for a severity and
207/// never learn the diagnostic should not exist.
208#[must_use]
209pub fn effective_severity(
210    code: brink_ir::DiagnosticCode,
211    types: TypePolicy,
212    lints: &LintPolicy,
213) -> Option<brink_ir::Severity> {
214    let base = if code == brink_ir::DiagnosticCode::E063 && types == TypePolicy::Strict {
215        brink_ir::Severity::Error
216    } else {
217        code.severity()
218    };
219
220    if base == brink_ir::Severity::Error && !code.is_compat_deny() {
221        // A hard error is never suppressible — `validate_lint_code` refuses
222        // an override for it in the first place, so reaching this with an
223        // `allow` in hand means the override was already rejected upstream.
224        // The one exception is the compat-deny tier (#3373, RULED
225        // 2026-09-01): those codes fall through to the same `[lints]`
226        // lookup every `Warning`/`Info`/`Hint`-base code already uses below.
227        return Some(base);
228    }
229
230    // The "candidate" severity before `deny-warnings` gets a look: an
231    // explicit `Deny`/`Allow`/`Info`/`Hint` override resolves (and returns)
232    // immediately, same as before #1674 — none of those four are ever
233    // touched by `deny-warnings` (`Allow`/`Info`/`Hint` are deliberate
234    // downgrades immune to it by design; `Deny` is already `Error`).
235    // `Warn`/unset both fall through to the shared `deny-warnings` check
236    // below — byte-identical to the pre-#1674 `Warning`-base-only version
237    // of this function when `base == Warning` (see this module's
238    // `info_base_code_*` tests for the new `Info`/`Hint`-base behavior this
239    // generalization adds).
240    let candidate = match lints.overrides.get(code.as_str()) {
241        Some(LintLevel::Deny) => return Some(brink_ir::Severity::Error),
242        Some(LintLevel::Allow) => return None,
243        Some(LintLevel::Info) => return Some(brink_ir::Severity::Info),
244        Some(LintLevel::Hint) => return Some(brink_ir::Severity::Hint),
245        // An explicit `warn` always means "Warning", regardless of the
246        // code's own base — the one case where the override outranks a
247        // non-`Warning` base.
248        Some(LintLevel::Warn) => brink_ir::Severity::Warning,
249        None => base,
250    };
251
252    Some(
253        if candidate == brink_ir::Severity::Warning && lints.deny_warnings {
254            brink_ir::Severity::Error
255        } else {
256            candidate
257        },
258    )
259}
260
261/// `types = strict` + `dialect != brink` is a project-level config error —
262/// there is no single offending span, so this reports once, attached to the
263/// first file in the project (mirroring how a whole-project condition with
264/// no natural per-construct site has to pick *some* file to carry it).
265/// `None` when the project has no files at all (nothing to attach to) or the
266/// dialect is already `brink` (no error).
267#[must_use]
268pub fn config_error(
269    dialect: crate::Dialect,
270    first_file: Option<FileId>,
271) -> Option<brink_ir::Diagnostic> {
272    if dialect == crate::Dialect::Brink {
273        return None;
274    }
275    let file = first_file?;
276    Some(brink_ir::Diagnostic {
277        file,
278        range: TextRange::new(0.into(), 0.into()),
279        message: "types = strict requires dialect = brink — strict typing's annotation syntax \
280                   is a brink-dialect extension (docs/typed-mode-spec.md §1); set \
281                   `dialect = brink` or drop back to `types = gradual`"
282            .to_owned(),
283        code: brink_ir::DiagnosticCode::E064,
284    })
285}
286
287/// The B0.9 native strict-only enforcement point (`docs/b0-sequencing.md`
288/// §B0.9's "the strict-only ruling's enforcement point", issue #1342;
289/// decision-log 2026-07-19 "Typing posture ruled": "the native surface is
290/// strict-only — `types = strict` is a property of the dialect, not a
291/// project knob; gradual typing does not exist on the native surface").
292///
293/// The inverse of [`config_error`] above in spirit — both are project-level
294/// `types` config errors with no single offending span — but a different
295/// axis: [`config_error`] rejects `types = strict` under the wrong
296/// **dialect** (an ink-only concept); this rejects an explicit `types =
297/// gradual` **knob** reaching a native (`.brink`) file, which has no
298/// dialect at all (`Language::Native` is a separate, path-derived
299/// classification — see `brink-db`'s `file_language` doc). Deliberately
300/// keyed on the *explicit* `AnalysisOptions::types` field, never the
301/// dialect-defaulted [`AnalysisOptions::type_policy`] result: a native
302/// project that never touches the `types` knob resolves through the
303/// ink-shaped `resolve_type_policy` default (which native's B0.10 dialect
304/// wiring has not yet overridden) and must not be penalized for a default
305/// it never chose — only a caller (CLI flag, `brink.toml`, editor/API call)
306/// that explicitly dials `types = gradual` for a native file hits this.
307///
308/// `None` when `explicit_types` isn't `Some(TypePolicy::Gradual)` (unset, or
309/// explicitly `Strict`) — the only two cases a native compile passes this
310/// gate.
311#[must_use]
312pub fn native_strict_only_error(
313    file: FileId,
314    explicit_types: Option<TypePolicy>,
315) -> Option<brink_ir::Diagnostic> {
316    if explicit_types != Some(TypePolicy::Gradual) {
317        return None;
318    }
319    Some(brink_ir::Diagnostic {
320        file,
321        range: TextRange::new(0.into(), 0.into()),
322        message: "native `.brink` compiles are strict-only — `types = gradual` is not a valid \
323                   policy for native source (docs/decision-log.md \"Typing posture ruled\", \
324                   2026-07-19); drop the `types` setting (native strict is the only policy) or \
325                   set `types = strict` explicitly"
326            .to_owned(),
327        code: brink_ir::DiagnosticCode::E137,
328    })
329}
330
331/// The strict-mode diagnostics that need a full `InferenceResult`:
332/// Unknown-escape (`E065`), Conflicted-escape (`E066`), void-assignment
333/// (`E067`), and — the inherited #640-round ruling — auto-wiring
334/// `annotations::mismatches` (`E063`) into production. Callers only reach
335/// this once [`config_error`] has confirmed `dialect = brink`.
336///
337/// `resolutions`: the project's full resolution map — the void-assignment
338/// pass needs it to resolve a call-site's `Path` back to the def it targets
339/// (the same range→`DefinitionId` lookup `infer::body` builds its own
340/// per-file projection of).
341///
342/// `manifest`: the registered host manifest (T1d-2, docs/t1d-spec.md §3) —
343/// the `Handle<K>` annotation-firewall vocabulary source, threaded through
344/// to [`check_escapes`] and `annotations::mismatches`. `None` degrades to an
345/// empty handle-kind set, same posture as every other manifest-driven check.
346#[must_use]
347pub fn check(
348    files: &[(FileId, &HirFile)],
349    index: &SymbolIndex,
350    inference: &InferenceResult,
351    resolutions: &ResolutionMap,
352    manifest: Option<&brink_ir::HostManifest>,
353) -> Vec<brink_ir::Diagnostic> {
354    let mut out = check_escapes(files, index, inference, manifest);
355    out.extend(annotations::mismatches(files, index, inference, manifest));
356    out.extend(check_void_assignments(files, index, resolutions, inference));
357    // T1c (docs/t1c-spec.md §4/§8): calls through function values are
358    // statically checked under strict — the facts inference already
359    // recorded map onto the existing TM-3 codes (E065/E066 escapes, E063
360    // typed mismatches), never parallel ones.
361    out.extend(check_value_calls(files, index, inference));
362    // Issue #1864: a direct call's arguments, checked against the resolved
363    // callee's already-known declared parameter types — the gap T1c's own
364    // `check_value_calls` above deliberately never covered (that pass is
365    // calls *through a value* specifically).
366    out.extend(check_direct_call_args(files, index, inference));
367    // Issue #1877 (the remainder of #1864 that PR #1875 left): a `~ temp`
368    // initializer against its own ascription, and a plain assignment
369    // against its target's already-known declared type — direct-call
370    // arguments' sibling gap, same E063 machinery.
371    out.extend(check_typed_assign_mismatches(files, index, inference));
372    // Issue #1994 (RULED 2026-08-01, closing #1932): a lambda's own written
373    // param/return annotation disagreeing with its body-derived type — an
374    // eager `Error`-severity `E174`, deliberately not folded into the
375    // gradual `E063` machinery above.
376    out.extend(check_lambda_annotation_mismatches(files, index, inference));
377    out.extend(check_global_initializers(files, index, manifest));
378    // Issue #1532 (#1501 review, migration-tail finding 1): `remove`'s
379    // pre-#1484 array leg has no compatibility shim — a statically-known
380    // array receiver is caught here instead of only at the `MapRemove`
381    // runtime fault.
382    out.extend(check_array_remove_calls(files, index, inference));
383    // Issue #1540 (second symptom): the UFCS spelling of that same check.
384    // `infer::body::infer_call` types a multi-segment callee `Unknown`
385    // before `infer_intrinsic` runs, so `arr.remove(0)` records no fact for
386    // `check_array_remove_calls` to read — the B3a verdict table is where
387    // the `(receiver type, verb)` pair survives. See `ufcs::check_strict`.
388    out.extend(crate::ufcs::check_strict(
389        files,
390        index,
391        resolutions,
392        inference,
393    ));
394    // TM-4b (docs/typed-mode-spec.md §6): missing/extra/mistyped struct
395    // construction-literal fields — strict-mode-only, per the crate doc.
396    out.extend(crate::structs::check(files, index, inference, resolutions));
397    // Issue #1900 (split from #1864/#1877): a *plain* dotted struct-field
398    // assignment target (`~ p.x = expr`) checked against the field's
399    // declared type — the E063 sibling of `check_typed_assign_mismatches`
400    // above for a multi-segment assignment target, which that check's own
401    // `check_declared_assign_target` explicitly declines.
402    out.extend(crate::structs::check_assignments(files, index, inference));
403    // T1e-1 (docs/t1e-spec.md §6, issue #831): a `ref lvalue-path`
404    // projection's segments (dotted fields, `[…]` indices) checked against
405    // the root's statically-known declared shape — strict-mode-only, same
406    // rule `structs::check`'s own missing/extra/mistyped trio follows,
407    // reusing the same shape table.
408    out.extend(crate::ref_projection::check_strict(
409        files,
410        index,
411        resolutions,
412    ));
413    // TM-3 completion (docs/typed-mode-spec.md §4, issue #659; extended to
414    // variable/call/index-valued arguments by issue #983): `int(x)`/
415    // `float(x)` statically out-of-domain arguments — strict-mode-only, per
416    // `conversions`'s own module doc.
417    out.extend(crate::conversions::check(
418        files,
419        index,
420        inference,
421        resolutions,
422    ));
423    // F27 (docs/stdlib-spec.md §1.6, ruled 2026-07-19, issue #1120):
424    // condition-position `Option[T]` has no truthiness — strict-mode-only,
425    // the compile-time half of the ruling (E116); the gradual-mode half is
426    // the runtime `OptionTruthiness` fault, which also backstops every
427    // statically-unclassifiable condition under strict.
428    out.extend(crate::option_conditions::check(
429        files,
430        index,
431        inference,
432        resolutions,
433    ));
434    // NS-A5 (docs/stdlib-spec.md §7, F7/F8, issue #1111): the inhabited-
435    // range refinement — `int(r)` demands `NonEmptyRange` evidence under
436    // strict (E117); gradual is inert with the runtime-fault residual.
437    // The template for every future value refinement.
438    out.extend(crate::range_refinement::check(
439        files,
440        index,
441        inference,
442        resolutions,
443    ));
444    // B1 `or`-coalescing (docs/stdlib-spec.md §1.6a, issue #1460; review
445    // finding on PR #1469): `infer::ty::coalesce`'s `LeftNotOption`/
446    // `Mismatch` failures, surfaced at the coalescing expression's own site
447    // — strict-mode-only, the compile-time half; gradual is inert with the
448    // runtime `TypeError` fault as the (narrower) residual backstop.
449    out.extend(crate::coalesce::check(files, index, inference, resolutions));
450    // `contains(m, needle)` static key-domain warning (E152, issue #582,
451    // companion to #580's ruling): a needle statically visible as outside
452    // the int/string/bool key domain, against a receiver statically
453    // visible as a map, always returns `false` at runtime — flagged at
454    // compile time rather than left as a silent always-false membership
455    // test. Strict-mode-only, same inference-substrate-backed domain-check
456    // family as `conversions`/`range_refinement` above (see
457    // `contains_domain`'s own module doc for why).
458    out.extend(crate::contains_domain::check(
459        files,
460        index,
461        inference,
462        resolutions,
463    ));
464    out
465}
466
467/// Unknown-escape (`E065`) + Conflicted-escape (`E066`) over every inferable
468/// def's params, return type, and temps. Return-value semantics — and so
469/// the return-type escape check plus the fall-through check ([`E150`],
470/// issue #1551) — apply to any def that is `is_function` (a `fn`) *or*
471/// carries a declared, non-`void` return-type annotation (a value-returning
472/// flow/stitch, the coroutine side of the ruled toggle,
473/// `docs/decision-log.md` 2026-07-22 implicit-end ruling item 3); an
474/// ordinary knot/stitch with neither has no return-value concept at all and
475/// stays entirely unchecked.
476///
477/// [`E150`]: brink_ir::DiagnosticCode::E150
478#[must_use]
479fn check_escapes(
480    files: &[(FileId, &HirFile)],
481    index: &SymbolIndex,
482    inference: &InferenceResult,
483    manifest: Option<&brink_ir::HostManifest>,
484) -> Vec<brink_ir::Diagnostic> {
485    let names = annotations::TypeNames::new(index, manifest);
486    let mut out = Vec::new();
487    for &(file, hir) in files {
488        for knot in &hir.knots {
489            let kind = knot.symbol_kind();
490            if let Some(id) = annotations::def_id_for(index, file, kind, &knot.name.text) {
491                // Issue #1591: "the body" for the return-value checks below
492                // is the knot's own block *plus* every one of its stitches
493                // — a stitch is reachable purely by fall-through when the
494                // knot's own body is empty, so a value-returning `return`
495                // living there counts the same as one in the knot's own
496                // block. See [`has_value_return_over_stitches`].
497                let body_has_value_return =
498                    has_value_return_over_stitches(knot, id, file, index, inference);
499                check_def(
500                    id,
501                    file,
502                    &knot.name.text,
503                    knot.name.range,
504                    knot.is_function,
505                    knot.return_type.as_ref(),
506                    &knot.params,
507                    &knot.body,
508                    &names,
509                    inference,
510                    body_has_value_return,
511                    &mut out,
512                );
513            }
514            for stitch in &knot.stitches {
515                let qualified = format!("{}.{}", knot.name.text, stitch.name.text);
516                if let Some(id) =
517                    annotations::def_id_for(index, file, SymbolKind::Stitch, &qualified)
518                {
519                    // `is_function` stays `false` (stitches never carry it,
520                    // per `lower_native::container`'s module doc) — the
521                    // real `return_type` (#1509) is forwarded regardless,
522                    // since #1551 made `check_def`'s return-value checks
523                    // (escape + fall-through) fire off a declared
524                    // `return_type` too, not just `is_function`. A stitch
525                    // has no nested stitches of its own (#1591's merge is
526                    // one level, owned by the knot above), so its own
527                    // `has_value_return` fact needs no merge here.
528                    let body_has_value_return = inference
529                        .bodies
530                        .get(&id)
531                        .is_some_and(|b| b.has_value_return);
532                    check_def(
533                        id,
534                        file,
535                        &qualified,
536                        stitch.name.range,
537                        false,
538                        stitch.return_type.as_ref(),
539                        &stitch.params,
540                        &stitch.body,
541                        &names,
542                        inference,
543                        body_has_value_return,
544                        &mut out,
545                    );
546                }
547            }
548        }
549    }
550    out
551}
552
553/// Unknown-escape (`E065`) + Conflicted-escape (`E066`) over every
554/// **registered** `EXTERNAL` declaration's own parameter types (issue #1004).
555///
556/// An `EXTERNAL name(params)` carries no ink-side type-annotation grammar
557/// (its parameters are bare identifiers — `external_declaration` parses no
558/// `(x: T)`), so a binding's declared parameter types can only come from the
559/// host manifest (or an inline `///` `@param` doc). [`check`] above walks
560/// `hir.knots` and therefore never sees these declarations; without this
561/// pass a manifest whose `ManifestParam.ty` fails to resolve (an empty `ty`,
562/// or one naming a semantic type absent from the `types` vocabulary) is
563/// silently treated as an untyped call rather than the strict escape it is.
564///
565/// The signatures come from [`crate::collect_external_sigs`] — the *same*
566/// resolution that seeds call-site argument checking into
567/// `infer_project`/`solve_scc` — so a param typed by the manifest resolves to
568/// its own [`Ty`] (a scalar semantic type as its `base`, a handle kind as
569/// `Ty::Handle`) and stays clean, while one that resolves to [`Ty::Unknown`]
570/// escapes. An `EXTERNAL` with *no* declared signature at all (neither a
571/// manifest entry nor an inline doc) is absent from `external_sigs` and stays
572/// entirely unchecked — the deliberate "unregistered external's call sites
573/// stay unchecked" posture (see `collect_external_sigs`'s own doc), so this
574/// never turns a bare, host-only `EXTERNAL` into a strict error.
575///
576/// Each diagnostic anchors at the external's *own* declaration span
577/// (`SymbolInfo::range`), fixing the #1004 secondary defect where every
578/// external escape collapsed onto one arbitrary line. Externals are visited
579/// in `(file, declaration offset)` order — the same deterministic ordering
580/// [`crate::external_check::analyze_externals`] uses — so diagnostic order is
581/// source order, not `DefinitionId`-hash order.
582#[must_use]
583pub(crate) fn check_external_escapes(
584    index: &SymbolIndex,
585    external_sigs: &BTreeMap<DefinitionId, InferredSig>,
586) -> Vec<brink_ir::Diagnostic> {
587    // Resolve each signed external to its `SymbolInfo`, then order by
588    // (file, declaration offset) for deterministic, source-ordered output.
589    let mut externals: Vec<(&brink_ir::SymbolInfo, &InferredSig)> = external_sigs
590        .iter()
591        .filter_map(|(id, sig)| index.symbols.get(id).map(|info| (info, sig)))
592        .filter(|(info, _)| info.kind == SymbolKind::External)
593        .collect();
594    externals.sort_by_key(|(info, _)| (info.file.0, info.range.start()));
595
596    let mut out = Vec::new();
597    for (info, sig) in externals {
598        for (i, param) in info.params.iter().enumerate() {
599            let ty = sig.params.get(i).unwrap_or(&Ty::Unknown);
600            emit_escape(
601                info.file,
602                &info.name,
603                &format!("parameter `{}`", param.name),
604                info.range,
605                ty,
606                // No inline-annotation exemption exists for an `EXTERNAL`
607                // (bare-identifier params): the resolved manifest/doc type
608                // *is* the declared type, so an `Unknown` here is a genuine
609                // "no resolvable type" escape, not a merely-uninferred slot.
610                false,
611                &mut out,
612            );
613        }
614    }
615    out
616}
617
618/// Whether **"the body"** of `knot` — for the `E150` fall-through check and
619/// `E067` inferred-void classification — ever carries a value-returning
620/// `return <expr>` anywhere: its own block **plus every one of its
621/// stitches** (`docs/typed-mode-spec.md` §3 ruling, issue #1591). A stitch
622/// is reachable from its owning knot purely by fall-through when the
623/// knot's own body is empty — no explicit divert required — or by an
624/// explicit divert; either way it is a continuation of the *same
625/// definition's* execution, not a separate callable, so a value-returning
626/// `return` anywhere in a stitch counts exactly like one in the knot's own
627/// block.
628///
629/// This reading does **not** extend to the `E065`/`E066` return-type
630/// escape check in [`check_def`]: that check reads `sig.return_ty`, which
631/// is inferred per-def and is never merged over stitches (only the
632/// has-value-return *fact* is merged here), so the escape branch keeps
633/// reading the def's own body (`body_types.has_value_return`) rather than
634/// this merged value — merging it there would make an inferred `Unknown`
635/// on the knot's own signature look "proven" by a sibling stitch's return,
636/// which is a different def with its own signature.
637///
638/// `Stitch` has no nested stitches in the HIR (`hir::types::Stitch` carries
639/// no `stitches` field), so this is exactly one level of merge, never
640/// recursive.
641///
642/// This is the **one** has-value-return-over-stitches reading shared by
643/// [`check_def`]'s `E150` fall-through check (called once per knot below)
644/// and [`collect_void_defs`]'s (`E067`) inferred-void classification.
645/// Issue #1551 fixed the E065/E066 + E150 checks' `is_function`-only
646/// gating; #1054/PR #1585 fixed `collect_void_defs`'s own copy of this
647/// exact stitch-merge read; #1591 is the E150 path's turn, done here by
648/// sharing instead of adding a fourth copy.
649fn has_value_return_over_stitches(
650    knot: &brink_ir::Knot,
651    own_id: DefinitionId,
652    file: FileId,
653    index: &SymbolIndex,
654    inference: &InferenceResult,
655) -> bool {
656    let own = inference
657        .bodies
658        .get(&own_id)
659        .is_some_and(|b| b.has_value_return);
660    own || knot.stitches.iter().any(|st| {
661        annotations::def_id_for(
662            index,
663            file,
664            SymbolKind::Stitch,
665            &format!("{}.{}", knot.name.text, st.name.text),
666        )
667        .and_then(|sid| inference.bodies.get(&sid))
668        .is_some_and(|b| b.has_value_return)
669    })
670}
671
672#[expect(clippy::too_many_arguments, reason = "internal helper, not public API")]
673fn check_def(
674    id: DefinitionId,
675    file: FileId,
676    def_label: &str,
677    name_range: TextRange,
678    is_function: bool,
679    return_type: Option<&TypeExpr>,
680    params: &[brink_ir::Param],
681    body: &Block,
682    names: &annotations::TypeNames,
683    inference: &InferenceResult,
684    // The has-value-return fact for the `E150` fall-through check below,
685    // read over "the body" as issue #1591 defines it (the caller's job —
686    // see [`has_value_return_over_stitches`] — since only the caller knows
687    // whether `id` is a knot with stitches to merge in or a stitch, which
688    // is always a leaf). The `E065`/`E066` return-type escape check does
689    // *not* use this merged fact — it reads `body_types.has_value_return`
690    // directly, since the signature it's checking is per-def and is never
691    // merged over stitches.
692    body_has_value_return: bool,
693    out: &mut Vec<brink_ir::Diagnostic>,
694) {
695    let Some(sig) = inference.signatures.get(&id) else {
696        return;
697    };
698    let Some(body_types) = inference.bodies.get(&id) else {
699        return;
700    };
701
702    // Params: an explicit, resolvable annotation supplies the concrete type
703    // — TM-2's firewall — and exempts the slot from Unknown-escape *only*.
704    // It never exempts Conflicted-escape: the body's own uses genuinely
705    // disagree with each other, which no annotation can resolve (mirrors
706    // `annotations::mismatches`' `is_unresolved()` treatment of Conflicted).
707    for (i, p) in params.iter().enumerate() {
708        let annotated = p
709            .annotation
710            .as_ref()
711            .is_some_and(|ann| annotations::resolve(ann, names).is_some());
712        let ty = sig.params.get(i).unwrap_or(&Ty::Unknown);
713        emit_escape(
714            file,
715            def_label,
716            &format!("parameter `{}`", p.name.text),
717            p.name.range,
718            ty,
719            annotated,
720            out,
721        );
722    }
723
724    // Return type: return-value semantics apply to a `fn` (`is_function`)
725    // *or* to any def carrying a declared, non-`void` return-type
726    // annotation (issue #1551 — a value-returning flow/stitch is the
727    // coroutine side of the ruled toggle, `docs/decision-log.md`
728    // 2026-07-22 implicit-end ruling item 3: "no return type ⇒ ends
729    // implicitly as DONE; has one ⇒ must return"). A `void`-annotated def
730    // never needs a concrete return value either way — `void` reads as "no
731    // return type" for this purpose on both a `fn` and a flow/stitch.
732    let has_void_annotation =
733        return_type.is_some_and(|rt| matches!(rt, TypeExpr::Named { name, .. } if name == "void"));
734    let declares_return_value = return_type.is_some() && !has_void_annotation;
735    if is_function || declares_return_value {
736        // Issue #1028 (originally `is_function`-only) / #1551 (generalized
737        // to any declared-return-value def): a body that never carries a
738        // value-returning `return <expr>` — it either falls off the end or
739        // only ever bare-`return`s — proves nothing ever flows out of it.
740        // `sig.return_ty.is_unknown()` alone can't distinguish "never
741        // returns a value" from "returns a value inference couldn't pin
742        // down" (a genuine Unknown-escape, handled in the `else` below).
743        //
744        // The two branches below read *different* has-value-return facts
745        // on purpose. The `else if` (E150 fall-through) reads
746        // `body_has_value_return`, the merged fact passed in by the caller
747        // (issue #1591: over the def's own block *plus* its stitches — see
748        // [`has_value_return_over_stitches`]) — a value-returning `return`
749        // reached purely by fall-through into a stitch still proves the
750        // *flow* returns a value. The Unknown-escape branch immediately
751        // below reads `body_types.has_value_return` — this def's own body
752        // only — because it's checking `sig.return_ty`, which inference
753        // computes per-def and never merges over stitches; merging the
754        // fact there without merging the signature would let a sibling
755        // stitch's return "prove" this knot's own Unknown return type,
756        // which is a different def with a different (still-Unknown)
757        // signature.
758        //
759        // What "never returns a value" *means* differs by whether a return
760        // value was promised:
761        //   - No declared return type (bare `fn`, typed-mode-spec §3 is
762        //     silent on this shape): inference shouldn't demand an
763        //     annotation to say what the body already proves — reads as
764        //     `void`, same as an explicit `: void` annotation. Nothing to
765        //     report.
766        //   - A declared, non-`void` return type: the author promised a
767        //     value every path must supply. Falling through is the ruled
768        //     **checker error** (`E150`, decision-log 2026-07-22 item 3),
769        //     never a silent implicit `void` — and never satisfied by an
770        //     implicit `-> DONE` synthesized at HIR lowering (`DONE` ends
771        //     the turn, not the value contract). This also fixes a latent
772        //     gap in the *pre-existing* `is_function` case: an annotated
773        //     `fn f(): int { … }` with no `return` anywhere previously
774        //     inferred `is_void = true` via the old blanket
775        //     `!has_value_return` short-circuit and skipped checking
776        //     entirely — silent despite the declared `int`.
777        // `!has_void_annotation` here matters even though `has_value_return`
778        // alone looks sufficient: a `: void`-annotated def whose body does
779        // carry a value-returning `return <expr>` (a body/annotation
780        // mismatch, not an escape) must not run the Unknown-escape check —
781        // `void` reads as "no return type" for escape purposes on both
782        // branches, so it also can't trip `E150` in the `else` below.
783        if body_types.has_value_return && !has_void_annotation {
784            let annotated = return_type.is_some_and(|rt| annotations::resolve(rt, names).is_some());
785            emit_escape(
786                file,
787                def_label,
788                "return type",
789                name_range,
790                &sig.return_ty,
791                annotated,
792                out,
793            );
794        } else if declares_return_value && !body_has_value_return {
795            out.push(brink_ir::Diagnostic {
796                file,
797                range: name_range,
798                message: format!(
799                    "`{def_label}` declares a return type but its body never returns a value"
800                ),
801                code: brink_ir::DiagnosticCode::E150,
802            });
803        }
804    }
805
806    // Temps: an explicit ascription (`~ temp x: T = ...`) exempts the slot
807    // the same way a param annotation does (Unknown-escape only, per above).
808    let param_names: std::collections::BTreeSet<&str> =
809        params.iter().map(|p| p.name.text.as_str()).collect();
810    let temp_decls = collect_temps(body, names);
811    for (name, ty) in &body_types.locals {
812        if param_names.contains(name.as_str()) {
813            continue; // already checked above, positionally + annotation-aware
814        }
815        let decl = temp_decls.get(name);
816        let annotated = decl.is_some_and(|d| d.annotation_ty.is_some());
817        let range = decl.map_or(name_range, |d| d.range);
818        emit_escape(
819            file,
820            def_label,
821            &format!("temp `{name}`"),
822            range,
823            ty,
824            annotated,
825            out,
826        );
827    }
828
829    // Issue #1770: give every lambda literal anywhere in this body the same
830    // Unknown-escape (`E065`) / Conflicted-escape (`E066`) treatment the
831    // params/temps loops above just gave `def_label` itself. Each
832    // `body_types.lambda_escapes` entry is already a fully-built
833    // `emit_escape` input (final type, declaration range,
834    // annotation-exemption bit, ready-made slot label) — recorded
835    // unconditionally by `infer::body::InferPass::infer_lambda` for every
836    // lambda anywhere in this body, including one nested inside another
837    // lambda's own body (see that field's own doc) — so this is a flat
838    // re-emit under the enclosing def's own label, no per-lambda grouping
839    // or lookup needed.
840    for slot in &body_types.lambda_escapes {
841        emit_escape(
842            file,
843            def_label,
844            &slot.slot_label,
845            slot.range,
846            &slot.ty,
847            slot.annotated,
848            out,
849        );
850    }
851}
852
853/// `annotated`: whether an explicit, resolvable annotation/ascription is
854/// present for this slot — exempts an `Unknown` classification (the
855/// annotation supplies the type TM-1 alone couldn't pin down) but never a
856/// `Conflicted` one (a genuine body-internal contradiction, which no
857/// annotation heals).
858fn emit_escape(
859    file: FileId,
860    def_label: &str,
861    slot_label: &str,
862    range: TextRange,
863    ty: &Ty,
864    annotated: bool,
865    out: &mut Vec<brink_ir::Diagnostic>,
866) {
867    match classify(ty) {
868        Escape::Clean => {}
869        Escape::Unknown if annotated => {}
870        Escape::Unknown => out.push(brink_ir::Diagnostic {
871            file,
872            range,
873            message: format!(
874                "`{def_label}`'s {slot_label} escapes strict inference as Unknown — \
875                 annotate or restructure"
876            ),
877            code: brink_ir::DiagnosticCode::E065,
878        }),
879        Escape::Conflicted => out.push(brink_ir::Diagnostic {
880            file,
881            range,
882            message: format!(
883                "`{def_label}`'s {slot_label} is Conflicted under strict types — its uses \
884                 disagree on its type (observed as `{}`)",
885                ty.display()
886            ),
887            code: brink_ir::DiagnosticCode::E066,
888        }),
889    }
890}
891
892enum Escape {
893    Clean,
894    Unknown,
895    Conflicted,
896}
897
898/// Recursively classify a type as clean, an Unknown-escape, or a
899/// Conflicted-escape — `Conflicted` wins whenever both appear inside the
900/// same `Array`/`Map` nesting (it is the stronger diagnosis: a genuine
901/// contradiction, not merely an unconstrained slot).
902fn classify(ty: &Ty) -> Escape {
903    match ty {
904        Ty::Conflicted => Escape::Conflicted,
905        Ty::Unknown => Escape::Unknown,
906        // Array, (NS-A1) `Option[T]`, and (NS-A7) `Weighted[T]` recurse on
907        // their single element — a parameterized builtin whose element is
908        // Unknown/Conflicted escapes like any other nesting.
909        Ty::Array(elem) | Ty::Option(elem) | Ty::Weighted(elem) => classify(elem),
910        Ty::Map(k, v) => match (classify(k), classify(v)) {
911            (Escape::Conflicted, _) | (_, Escape::Conflicted) => Escape::Conflicted,
912            (Escape::Unknown, _) | (_, Escape::Unknown) => Escape::Unknown,
913            (Escape::Clean, Escape::Clean) => Escape::Clean,
914        },
915        // T1c `fn(T…): R` (docs/t1c-spec.md §4): the same recursive lattice
916        // walk as Array/Map — a fn value whose row carries Unknown or
917        // Conflicted slots can't be call-checked, so it escapes like any
918        // other nesting. (In practice the row comes from the target's own
919        // inferred signature, so the target def carries the root-cause
920        // E065/E066 too.)
921        Ty::Fn(params, ret, _) => {
922            params
923                .iter()
924                .chain(std::iter::once(ret.as_ref()))
925                .fold(Escape::Clean, |acc, t| match (acc, classify(t)) {
926                    (Escape::Conflicted, _) | (_, Escape::Conflicted) => Escape::Conflicted,
927                    (Escape::Unknown, _) | (_, Escape::Unknown) => Escape::Unknown,
928                    (Escape::Clean, Escape::Clean) => Escape::Clean,
929                })
930        }
931        // TM-4b (docs/typed-mode-spec.md §6): "struct-typed slots are
932        // concrete for E065/E066 purposes" — a resolved `Ty::Struct` is as
933        // clean as any other nominal (`Ty::List`'s existing precedent).
934        // T1d-2 (docs/t1d-spec.md §3): a resolved `Ty::Handle` is equally
935        // concrete — reusing TM-3's existing E065/E066 vocabulary is exactly
936        // the spec's "strict kind-checking via existing TM-3 machinery, no
937        // new codes" ruling. A *cross-kind* mismatch never reaches this
938        // function as `Ty::Handle` at all — `unify` already folds it to
939        // `Ty::Conflicted` at the point the two kinds meet, so it's caught
940        // by the `Ty::Conflicted` arm above, not here.
941        // NS-A5: a resolved `Ty::Range` is concrete either way — the
942        // `non_empty` refinement bit is evidence, not openness; a missing
943        // refinement is E117's business (range_refinement), never an
944        // Unknown-escape.
945        // (NS-A8 tower kinds are concrete leaves — clean, like scalars.)
946        // A resolved `Ty::Content` (issue #1846) is equally concrete —
947        // fragment-backed, not an openness axis; strict escape-checking
948        // treats it exactly like any other nominal leaf.
949        Ty::Int
950        | Ty::Float
951        | Ty::Bool
952        | Ty::String
953        | Ty::Content
954        | Ty::Divert
955        | Ty::List(_)
956        | Ty::Struct(_)
957        | Ty::Handle(_)
958        | Ty::Range { .. }
959        | Ty::Tower(_) => Escape::Clean,
960    }
961}
962
963// ── T1c: calls through function values (docs/t1c-spec.md §4/§8) ───────
964
965/// Report every [`crate::infer::ValueCallFact`] inference recorded, per
966/// def, using the existing TM-3 vocabulary:
967///
968/// - `Unknown` callee → `E065` (the escape rule applied to call position:
969///   "a strict-mode author can never reach the §3 runtime fault");
970/// - `Conflicted` callee → `E066`;
971/// - known-type mismatches (non-callable type, arity, argument type) →
972///   `E063` (typed-mismatch reporting extended to call-through-value
973///   sites — `Error` under strict via [`effective_severity`]).
974fn check_value_calls(
975    files: &[(FileId, &HirFile)],
976    index: &SymbolIndex,
977    inference: &InferenceResult,
978) -> Vec<brink_ir::Diagnostic> {
979    use crate::infer::ValueCallKind;
980
981    let mut out = Vec::new();
982    for &(file, hir) in files {
983        let mut def_ids: Vec<DefinitionId> = Vec::new();
984        for knot in &hir.knots {
985            let kind = knot.symbol_kind();
986            if let Some(id) = annotations::def_id_for(index, file, kind, &knot.name.text) {
987                def_ids.push(id);
988            }
989            for stitch in &knot.stitches {
990                let qualified = format!("{}.{}", knot.name.text, stitch.name.text);
991                if let Some(id) =
992                    annotations::def_id_for(index, file, SymbolKind::Stitch, &qualified)
993                {
994                    def_ids.push(id);
995                }
996            }
997        }
998        for id in def_ids {
999            let Some(body) = inference.bodies.get(&id) else {
1000                continue;
1001            };
1002            for fact in &body.value_calls {
1003                let callee = &fact.callee;
1004                let (message, code) = match &fact.kind {
1005                    ValueCallKind::UnknownCallee => (
1006                        format!(
1007                            "`{callee}` is called as a function value but its type escapes \
1008                             strict inference as Unknown — annotate (`fn(T…): R`) or \
1009                             restructure"
1010                        ),
1011                        brink_ir::DiagnosticCode::E065,
1012                    ),
1013                    ValueCallKind::ConflictedCallee => (
1014                        format!(
1015                            "`{callee}` is called as a function value but its type is \
1016                             Conflicted under strict types — its uses disagree"
1017                        ),
1018                        brink_ir::DiagnosticCode::E066,
1019                    ),
1020                    ValueCallKind::NotCallable(ty) => (
1021                        format!(
1022                            "`{callee}` has type `{}` — not callable (a `fn(T…): R` \
1023                             function value is required in call position)",
1024                            ty.display()
1025                        ),
1026                        brink_ir::DiagnosticCode::E063,
1027                    ),
1028                    ValueCallKind::ArityMismatch { expected, got } => (
1029                        format!(
1030                            "call through `{callee}` supplies {got} argument(s) but its \
1031                             known type expects {expected}"
1032                        ),
1033                        brink_ir::DiagnosticCode::E063,
1034                    ),
1035                    ValueCallKind::ArgMismatch {
1036                        index,
1037                        expected,
1038                        found,
1039                    } => (
1040                        format!(
1041                            "argument {} of call through `{callee}` has type `{}` but its \
1042                             known type expects `{}`",
1043                            index + 1,
1044                            found.display(),
1045                            expected.display()
1046                        ),
1047                        brink_ir::DiagnosticCode::E063,
1048                    ),
1049                    ValueCallKind::OverBind { available, got } => (
1050                        format!(
1051                            "`bind` through `{callee}` supplies {got} argument(s) but only \
1052                             {available} parameter(s) remain in its known type"
1053                        ),
1054                        brink_ir::DiagnosticCode::E063,
1055                    ),
1056                };
1057                out.push(brink_ir::Diagnostic {
1058                    file,
1059                    range: fact.range,
1060                    message,
1061                    code,
1062                });
1063            }
1064        }
1065    }
1066    out
1067}
1068
1069// ── Direct-call + `#fn` creation-site argument types (issues #1864, #2001) ──
1070
1071/// Report every [`crate::infer::DirectCallArgMismatch`] inference recorded,
1072/// per def, as `E063` — the same typed-mismatch code
1073/// [`check_value_calls`]'s own `ArgMismatch` arm reports for a call
1074/// *through a value*; a direct call resolving straight to a known def via
1075/// `known_sigs` is the same "declared type disagrees with what a caller
1076/// passed" fact, just without a value in between (docs/t1c-spec.md §8's
1077/// "existing TM-3 machinery, no new codes" posture, applied to the direct-
1078/// call case #1864 identified as unchecked). Same shape as
1079/// [`check_value_calls`]: walk every inferable def, read its recorded
1080/// facts, map each straight onto one diagnostic.
1081///
1082/// As of #2001, [`crate::infer::DirectCallArgMismatch`] also carries facts
1083/// from a second producer that is not a call at all: a `#fn(target, args…)`
1084/// literal's bound-argument list, which is the by-ref *creation* site for a
1085/// partial application (see that struct's own doc). As of #2127, a third
1086/// producer joins them: a divert-with-arguments (`-> knot(a, b)`) `ref`
1087/// position. All three map onto the same `E063` message ("argument N of
1088/// call to `name`") — accepted as close enough for the creation-site and
1089/// divert-target cases too rather than adding a call-vs-creation-vs-divert
1090/// discriminant; see [`crate::infer::DirectCallArgMismatch`] for that call.
1091///
1092/// `infer::body::InferPass::arg_is_observed_local` already excludes an
1093/// argument `InferPass::observe` itself would join `param_ty` into, so
1094/// every fact reaching here is disjoint from `check_escapes`'s own
1095/// Conflicted-escape (`E066`) reporting for the same call/creation site —
1096/// no dedup needed on this side.
1097fn check_direct_call_args(
1098    files: &[(FileId, &HirFile)],
1099    index: &SymbolIndex,
1100    inference: &InferenceResult,
1101) -> Vec<brink_ir::Diagnostic> {
1102    let mut out = Vec::new();
1103    for &(file, hir) in files {
1104        let mut def_ids: Vec<DefinitionId> = Vec::new();
1105        // Issue #1903: add root_content's synthetic ID to the list of defs
1106        // to check, just as collect_defs synthesizes it for inference.
1107        // Mirrors check_typed_assign_mismatches below — without this, a
1108        // direct call (or #fn literal) at the top level of an ink file's
1109        // root_content silently drops its recorded facts (2026-08 review,
1110        // issue #2001).
1111        if !hir.root_content.stmts.is_empty() {
1112            let synthetic_id = crate::infer::root_content_def_id(file);
1113            def_ids.push(synthetic_id);
1114        }
1115        for knot in &hir.knots {
1116            let kind = knot.symbol_kind();
1117            if let Some(id) = annotations::def_id_for(index, file, kind, &knot.name.text) {
1118                def_ids.push(id);
1119            }
1120            for stitch in &knot.stitches {
1121                let qualified = format!("{}.{}", knot.name.text, stitch.name.text);
1122                if let Some(id) =
1123                    annotations::def_id_for(index, file, SymbolKind::Stitch, &qualified)
1124                {
1125                    def_ids.push(id);
1126                }
1127            }
1128        }
1129        for id in def_ids {
1130            let Some(body) = inference.bodies.get(&id) else {
1131                continue;
1132            };
1133            for fact in &body.direct_call_arg_mismatches {
1134                out.push(brink_ir::Diagnostic {
1135                    file,
1136                    range: fact.range,
1137                    message: format!(
1138                        "argument {} of call to `{}` has type `{}` but its known \
1139                         type expects `{}`",
1140                        fact.index + 1,
1141                        fact.callee,
1142                        fact.found.display(),
1143                        fact.expected.display()
1144                    ),
1145                    code: brink_ir::DiagnosticCode::E063,
1146                });
1147            }
1148        }
1149    }
1150    out
1151}
1152
1153// ── `~ temp` initializers + plain assignments (issue #1877) ───────────
1154
1155/// Report every [`crate::infer::TypedAssignMismatch`] inference recorded,
1156/// per def, as `E063` — the same typed-mismatch code
1157/// [`check_direct_call_args`] reports for a direct call's arguments. Issue
1158/// #1877 is the remainder of #1864 that PR #1875 explicitly left: that PR
1159/// checked direct-call arguments against a callee's declared param types;
1160/// this checks a `~ temp name: T = expr` initializer against its own
1161/// ascription, and a plain `~ name = expr` assignment against the target's
1162/// already-known declared type (a VAR/CONST's declaration-derived type, or
1163/// an annotated Param/Temp's ascription). Same shape as
1164/// [`check_direct_call_args`]: walk every inferable def, read its recorded
1165/// facts, map each straight onto one diagnostic.
1166///
1167/// `infer::body::InferPass::check_declared_assign_target` and
1168/// `check_declared_temp_init` each exclude a Temp write whose own `observe`/
1169/// `bind_local` join is about to drive it to `Ty::Conflicted` *on that exact
1170/// write*; `infer::body::InferPass::
1171/// drop_typed_assign_mismatches_conflicted_by_a_later_read` (run post-walk,
1172/// from `infer_def_body` via `InferPass::finish_walk`) additionally drops
1173/// any fact whose target's *final* whole-body type ends up `Conflicted` —
1174/// the guard is per-write and order-sensitive, so a later read of the same
1175/// local (not just the write that produced the fact) can also conflict it,
1176/// and only the post-walk pass sees that. Between the two, every fact
1177/// reaching here is disjoint from `check_escapes`'s own Conflicted-escape
1178/// (`E066`) reporting for the same local, no dedup needed
1179/// on this side (mirrors [`check_direct_call_args`]'s own doc on the
1180/// identical point).
1181/// Every top-level def id a body-level check (typed-assign mismatches,
1182/// lambda annotation mismatches) needs to walk for one file: each
1183/// knot/stitch, plus (issue #1903) `root_content`'s own synthetic id when
1184/// the file has top-level content of its own — `collect_defs` synthesizes
1185/// that same id for inference, so a body-level check must look it up under
1186/// the identical scheme or it silently never sees a lambda/assignment
1187/// written directly in a file's top-level content. Factored out of
1188/// [`check_typed_assign_mismatches`] and [`check_lambda_annotation_mismatches`]
1189/// (previously a character-for-character copy in each, house rule on
1190/// keeping a single walk shared once it needs a second issue-specific fix
1191/// threaded through it) so the next such fix only has to land once.
1192fn body_def_ids(file: FileId, hir: &HirFile, index: &SymbolIndex) -> Vec<DefinitionId> {
1193    let mut def_ids: Vec<DefinitionId> = Vec::new();
1194    if !hir.root_content.stmts.is_empty() {
1195        let synthetic_id = crate::infer::root_content_def_id(file);
1196        def_ids.push(synthetic_id);
1197    }
1198    for knot in &hir.knots {
1199        let kind = knot.symbol_kind();
1200        if let Some(id) = annotations::def_id_for(index, file, kind, &knot.name.text) {
1201            def_ids.push(id);
1202        }
1203        for stitch in &knot.stitches {
1204            let qualified = format!("{}.{}", knot.name.text, stitch.name.text);
1205            if let Some(id) = annotations::def_id_for(index, file, SymbolKind::Stitch, &qualified) {
1206                def_ids.push(id);
1207            }
1208        }
1209    }
1210    def_ids
1211}
1212
1213fn check_typed_assign_mismatches(
1214    files: &[(FileId, &HirFile)],
1215    index: &SymbolIndex,
1216    inference: &InferenceResult,
1217) -> Vec<brink_ir::Diagnostic> {
1218    let mut out = Vec::new();
1219    for &(file, hir) in files {
1220        for id in body_def_ids(file, hir, index) {
1221            let Some(body) = inference.bodies.get(&id) else {
1222                continue;
1223            };
1224            for fact in &body.typed_assign_mismatches {
1225                out.push(brink_ir::Diagnostic {
1226                    file,
1227                    range: fact.range,
1228                    message: format!(
1229                        "`{}` has type `{}` but its declared type is `{}`",
1230                        fact.target,
1231                        fact.found.display(),
1232                        fact.expected.display()
1233                    ),
1234                    code: brink_ir::DiagnosticCode::E063,
1235                });
1236            }
1237        }
1238    }
1239    out
1240}
1241
1242/// Report every [`crate::infer::LambdaAnnotationMismatch`] inference
1243/// recorded, per def, as `E174` (issue #1994, RULED 2026-08-01, closing
1244/// #1932). Same walk shape as [`check_typed_assign_mismatches`] above (every
1245/// fact was harvested onto whichever top-level def's own `BodyResult` the
1246/// lambda that produced it was nested inside — `infer::body::InferPass`
1247/// never snapshots this accumulator around a lambda frame, see that
1248/// struct's own field doc), but a materially different severity posture:
1249/// unlike `E063`'s gradual/advisory "two independent derivations, compared
1250/// but never merged", a lambda's own written annotation now *replaces* its
1251/// body-derived type at this slot (`infer::body::InferPass::infer_lambda`'s
1252/// own doc), so a disagreement recorded here is never merely a warning —
1253/// `E174`'s default severity is `Error`, not downgradable the way `E063` is.
1254fn check_lambda_annotation_mismatches(
1255    files: &[(FileId, &HirFile)],
1256    index: &SymbolIndex,
1257    inference: &InferenceResult,
1258) -> Vec<brink_ir::Diagnostic> {
1259    let mut out = Vec::new();
1260    for &(file, hir) in files {
1261        for id in body_def_ids(file, hir, index) {
1262            let Some(body) = inference.bodies.get(&id) else {
1263                continue;
1264            };
1265            for fact in &body.lambda_annotation_mismatches {
1266                let message = match &fact.param_name {
1267                    Some(name) => format!(
1268                        "lambda parameter `{name}` is annotated `{}` but its body infers `{}`",
1269                        fact.expected.display(),
1270                        fact.found.display()
1271                    ),
1272                    None => format!(
1273                        "lambda return type is annotated `{}` but its body infers `{}`",
1274                        fact.expected.display(),
1275                        fact.found.display()
1276                    ),
1277                };
1278                out.push(brink_ir::Diagnostic {
1279                    file,
1280                    range: fact.range,
1281                    message,
1282                    code: brink_ir::DiagnosticCode::E174,
1283                });
1284            }
1285        }
1286    }
1287    out
1288}
1289
1290// ── VAR/CONST declaration initializers (issue #1877) ──────────────────
1291
1292/// Report a VAR/CONST declaration whose explicit `: type` annotation
1293/// disagrees with its own initializer literal's independently-inferred
1294/// type, as `E063` — the declaration-initializer sibling of
1295/// [`check_typed_assign_mismatches`] above, for the one declaration shape
1296/// that has no enclosing body to walk (`hir.variables`/`hir.constants` are
1297/// file-level, not per-def facts inference records).
1298///
1299/// TM-2's firewall (`signature::declared_value_ty`'s own doc: "annotation
1300/// *replaces* [the initializer-inferred type]") means `Sig::value_ty` for an
1301/// annotated VAR/CONST is the annotation alone — the initializer's own
1302/// independently-inferred type is computed and then silently discarded,
1303/// never compared against it. This is that comparison, reusing
1304/// `signature::literal_ty` (the same collection-aware literal-typing
1305/// `Sig::value_ty`'s own fallback branch already calls) rather than
1306/// re-deriving it.
1307///
1308/// Declaration-derived only, like the rest of `signature.rs`: a non-literal
1309/// initializer (a call, an index, a reference to another global, `#fn(…)`)
1310/// has no `literal_ty` here and is silently unchecked — the runtime
1311/// type-mismatch fault (gradual) or a body-inference-driven check (were one
1312/// to exist for globals — TM-3's module doc already notes cross-
1313/// reassignment detection for globals is out of scope) is the backstop, not
1314/// this stub.
1315fn check_global_initializers(
1316    files: &[(FileId, &HirFile)],
1317    index: &SymbolIndex,
1318    manifest: Option<&brink_ir::HostManifest>,
1319) -> Vec<brink_ir::Diagnostic> {
1320    let names = annotations::TypeNames::new(index, manifest);
1321    let mut out = Vec::new();
1322    for &(file, hir) in files {
1323        for v in &hir.variables {
1324            check_one_global_initializer(
1325                &v.name.text,
1326                &v.value,
1327                v.annotation.as_ref(),
1328                file,
1329                index,
1330                &names,
1331                &mut out,
1332            );
1333        }
1334        for c in &hir.constants {
1335            check_one_global_initializer(
1336                &c.name.text,
1337                &c.value,
1338                c.annotation.as_ref(),
1339                file,
1340                index,
1341                &names,
1342                &mut out,
1343            );
1344        }
1345    }
1346    out
1347}
1348
1349fn check_one_global_initializer(
1350    name: &str,
1351    value: &Expr,
1352    annotation: Option<&TypeExpr>,
1353    file: FileId,
1354    index: &SymbolIndex,
1355    names: &annotations::TypeNames,
1356    out: &mut Vec<brink_ir::Diagnostic>,
1357) {
1358    let Some(te) = annotation else { return };
1359    let Some(ann_ty) = annotations::resolve(te, names) else {
1360        return;
1361    };
1362    let Some(lit_ty) = crate::signature::literal_ty(value, index) else {
1363        return;
1364    };
1365    if lit_ty.is_unresolved() || crate::infer::assignable(&ann_ty, &lit_ty) {
1366        return;
1367    }
1368    out.push(brink_ir::Diagnostic {
1369        file,
1370        range: te.range(),
1371        message: format!(
1372            "`{name}`'s declared type `{}` disagrees with its initializer's type (`{}`)",
1373            ann_ty.display(),
1374            lit_ty.display()
1375        ),
1376        code: brink_ir::DiagnosticCode::E063,
1377    });
1378}
1379
1380// ── `remove`/`remove_at` migration tail (issue #1532, `E149`) ─────────
1381
1382/// Report every [`crate::infer::body`]-recorded array-typed `remove(a, i)`
1383/// call site (`BodyResult::array_remove_calls`, threaded through
1384/// [`crate::infer::BodyTypes`]) as `E149`. Same shape as
1385/// [`check_value_calls`] — walk every inferable def, read its recorded
1386/// facts, map each straight onto one diagnostic — but the fact carries no
1387/// per-call detail to interpolate (the message is fixed), so there is no
1388/// `match` here.
1389fn check_array_remove_calls(
1390    files: &[(FileId, &HirFile)],
1391    index: &SymbolIndex,
1392    inference: &InferenceResult,
1393) -> Vec<brink_ir::Diagnostic> {
1394    let mut out = Vec::new();
1395    for &(file, hir) in files {
1396        let mut def_ids: Vec<DefinitionId> = Vec::new();
1397        for knot in &hir.knots {
1398            let kind = knot.symbol_kind();
1399            if let Some(id) = annotations::def_id_for(index, file, kind, &knot.name.text) {
1400                def_ids.push(id);
1401            }
1402            for stitch in &knot.stitches {
1403                let qualified = format!("{}.{}", knot.name.text, stitch.name.text);
1404                if let Some(id) =
1405                    annotations::def_id_for(index, file, SymbolKind::Stitch, &qualified)
1406                {
1407                    def_ids.push(id);
1408                }
1409            }
1410        }
1411        for id in def_ids {
1412            let Some(body) = inference.bodies.get(&id) else {
1413                continue;
1414            };
1415            for &range in &body.array_remove_calls {
1416                out.push(brink_ir::Diagnostic {
1417                    file,
1418                    range,
1419                    message: brink_ir::DiagnosticCode::E149.title().to_owned(),
1420                    code: brink_ir::DiagnosticCode::E149,
1421                });
1422            }
1423        }
1424    }
1425    out
1426}
1427
1428// ── Void-assignment (E067, docs/typed-mode-spec.md §3) ────────────────
1429
1430/// `(start, end)` `u32` pair — `TextRange` has no `Ord` impl, so every
1431/// `BTreeMap` keyed by a source range in this module uses this instead
1432/// (mirrors `infer::mod`'s own `range_key`).
1433fn range_key(range: TextRange) -> (u32, u32) {
1434    (range.start().into(), range.end().into())
1435}
1436
1437/// `~ x = f()` / `~ temp x = f()` where `f`'s resolved def is a `void`-
1438/// returning function — whether by an explicit `): void ===` annotation or
1439/// by inference (issue #1054: a `fn` with no declared return type whose
1440/// body never carries a value-returning `return`, the same inferred-void
1441/// shape #1046 taught the return-type escape check to recognize) — is a
1442/// compile error under strict (spec §3: "assigning a `void` call is an
1443/// error in strict mode"). Only the assignment/temp-decl's RHS *root*
1444/// expression is checked — a statement-position call (`~ f()`) or a call
1445/// nested inside interpolation is never flagged, since neither assigns the
1446/// (nonexistent) result anywhere.
1447#[must_use]
1448fn check_void_assignments(
1449    files: &[(FileId, &HirFile)],
1450    index: &SymbolIndex,
1451    resolutions: &ResolutionMap,
1452    inference: &InferenceResult,
1453) -> Vec<brink_ir::Diagnostic> {
1454    let void_defs = collect_void_defs(files, index, inference);
1455    if void_defs.is_empty() {
1456        return Vec::new();
1457    }
1458    let mut out = Vec::new();
1459    for &(file, hir) in files {
1460        let resolution_by_range = resolution_index(resolutions, file);
1461        for knot in &hir.knots {
1462            check_void_block(file, &knot.body, &void_defs, &resolution_by_range, &mut out);
1463            for stitch in &knot.stitches {
1464                check_void_block(
1465                    file,
1466                    &stitch.body,
1467                    &void_defs,
1468                    &resolution_by_range,
1469                    &mut out,
1470                );
1471            }
1472        }
1473    }
1474    out
1475}
1476
1477/// Every function knot that is `void`, by `DefinitionId` — either by an
1478/// explicit `): void ===` return annotation, or by inference (issue #1054):
1479/// a `fn` with *no* declared return type whose body never carries a
1480/// value-returning `return <expr>` reads as void the same way `check_def`'s
1481/// return-type escape check already treats it (issue #1046's "an
1482/// unannotated, never-returning function infers void" ruling — this is that
1483/// same fact, read here for the void-*assignment* check rather than the
1484/// escape check). A declared, *non*-`void` return type whose body never
1485/// returns a value is a different shape entirely — the checker error
1486/// `E150` (issue #1551), not void — so it is deliberately excluded here:
1487/// `knot.return_type.is_none()` gates the inferred branch, meaning only a
1488/// bare `fn` with no annotation at all can infer void.
1489///
1490/// Only `is_function` knots are function calls in the sense this check
1491/// cares about (a value-returning *non-function* flow/stitch is the
1492/// coroutine side of the NG-C/#1509 toggle, not a callable void-or-not
1493/// function) — so only `hir.knots` entries with `is_function` set are
1494/// candidates, mirroring `check_escapes`' own def-id lookup (`kind` tracks
1495/// `knot.ptr`, since a top-level stitch promoted to knot status is indexed
1496/// under `SymbolKind::Stitch`, #626). A *nested* `Stitch` never carries
1497/// `is_function` (no HIR container below `Knot` does), so it is never a
1498/// candidate here regardless of its own `return_type` (#1509).
1499///
1500/// The inferred branch's "never carries a value-returning `return`" check
1501/// must also account for the function's own stitches: a fall-through
1502/// `-> f.sub` (or a conditional divert into one) reaches a stitch's body,
1503/// which is a *separate* `Def` (`infer::collect_defs`, qualified name
1504/// `f.sub`, `SymbolKind::Stitch`) with its own `BodyTypes` in
1505/// `inference.bodies` — the knot's own `BodyTypes` only covers content
1506/// before the first stitch. A knot is only inferred-void when neither its
1507/// own body nor any of its stitches carries a value-returning `return` —
1508/// [`has_value_return_over_stitches`] is that shared reading (issue #1591).
1509fn collect_void_defs(
1510    files: &[(FileId, &HirFile)],
1511    index: &SymbolIndex,
1512    inference: &InferenceResult,
1513) -> BTreeSet<DefinitionId> {
1514    let mut out = BTreeSet::new();
1515    for &(file, hir) in files {
1516        for knot in &hir.knots {
1517            if !knot.is_function {
1518                continue;
1519            }
1520            let kind = knot.symbol_kind();
1521            let Some(id) = annotations::def_id_for(index, file, kind, &knot.name.text) else {
1522                continue;
1523            };
1524            let has_void_annotation = knot
1525                .return_type
1526                .as_ref()
1527                .is_some_and(|rt| matches!(rt, TypeExpr::Named { name, .. } if name == "void"));
1528            // `has_value_return_over_stitches` reads `false` for a def with
1529            // no `inference.bodies` entry at all, same as it reads `false`
1530            // for a body that has one but never returns a value — the two
1531            // are different facts ("never inferred" vs. "inferred, no
1532            // value return") and only the latter should count as
1533            // inferred-void (mirrors the pre-dedupe
1534            // `inference.bodies.get(&id).is_some_and(|bt|
1535            // !bt.has_value_return)` guard this replaced).
1536            let inferred_void = knot.return_type.is_none()
1537                && inference.bodies.contains_key(&id)
1538                && !has_value_return_over_stitches(knot, id, file, index, inference);
1539            if has_void_annotation || inferred_void {
1540                out.insert(id);
1541            }
1542        }
1543    }
1544    out
1545}
1546
1547/// This file's own reference resolutions, projected to a range-keyed lookup
1548/// (mirrors `infer::mod`'s `index_resolutions_by_file`, narrowed to one file
1549/// at a time — a `Path`'s range is only unique within its own file).
1550fn resolution_index(
1551    resolutions: &ResolutionMap,
1552    file: FileId,
1553) -> BTreeMap<(u32, u32), DefinitionId> {
1554    resolutions
1555        .iter()
1556        .filter(|r| r.file == file)
1557        .map(|r| (range_key(r.range), r.target))
1558        .collect()
1559}
1560
1561fn check_void_block(
1562    file: FileId,
1563    block: &Block,
1564    void_defs: &BTreeSet<DefinitionId>,
1565    resolution_by_range: &BTreeMap<(u32, u32), DefinitionId>,
1566    out: &mut Vec<brink_ir::Diagnostic>,
1567) {
1568    for stmt in &block.stmts {
1569        check_void_stmt(file, stmt, void_defs, resolution_by_range, out);
1570    }
1571}
1572
1573fn check_void_stmt(
1574    file: FileId,
1575    stmt: &Stmt,
1576    void_defs: &BTreeSet<DefinitionId>,
1577    resolution_by_range: &BTreeMap<(u32, u32), DefinitionId>,
1578    out: &mut Vec<brink_ir::Diagnostic>,
1579) {
1580    match stmt {
1581        Stmt::TempDecl(t) => {
1582            if let Some(value) = &t.value {
1583                check_void_root(file, value, void_defs, resolution_by_range, out);
1584            }
1585        }
1586        Stmt::Assignment(a) => {
1587            check_void_root(file, &a.value, void_defs, resolution_by_range, out);
1588        }
1589        Stmt::ChoiceSet(cs) => {
1590            for choice in &cs.choices {
1591                check_void_block(file, &choice.body, void_defs, resolution_by_range, out);
1592                if let Some(c) = &choice.start_content {
1593                    check_void_content(file, c, void_defs, resolution_by_range, out);
1594                }
1595                if let Some(c) = &choice.bracket_content {
1596                    check_void_content(file, c, void_defs, resolution_by_range, out);
1597                }
1598                if let Some(c) = &choice.inner_content {
1599                    check_void_content(file, c, void_defs, resolution_by_range, out);
1600                }
1601            }
1602            check_void_block(file, &cs.continuation, void_defs, resolution_by_range, out);
1603        }
1604        Stmt::LabeledBlock(b) => check_void_block(file, b, void_defs, resolution_by_range, out),
1605        Stmt::Conditional(c) => {
1606            for branch in &c.branches {
1607                check_void_block(file, &branch.body, void_defs, resolution_by_range, out);
1608            }
1609        }
1610        Stmt::Sequence(s) => {
1611            for branch in &s.branches {
1612                check_void_block(file, &branch.body, void_defs, resolution_by_range, out);
1613            }
1614        }
1615        Stmt::Content(c) => check_void_content(file, c, void_defs, resolution_by_range, out),
1616        Stmt::LogicBlock(lb) => {
1617            for bs in &lb.stmts {
1618                check_void_block_stmt(file, bs, void_defs, resolution_by_range, out);
1619            }
1620        }
1621        // `~ await <cond>` (docs/flow-suspension-spec.md §3): the condition is
1622        // a value position, so a void-returning call used there is the same
1623        // strict-mode error it is anywhere else a value is expected.
1624        Stmt::Await(a) => {
1625            if let Some(cond) = &a.condition {
1626                check_void_root(file, cond, void_defs, resolution_by_range, out);
1627            }
1628        }
1629        // Issue #2108: unlike `ExprStmt` (a call's result deliberately
1630        // discarded), an attach handler's call result is a used value — the
1631        // struct whose fields become attached element data — so a
1632        // void-returning call here is checked the same as `Assignment`/
1633        // `TempDecl`'s value position. (`attach = StructName`'s own E180
1634        // check already requires the declaration's return type to name a
1635        // real struct, so this arm is not expected to ever fire in
1636        // practice — but it costs nothing to check rather than assume.)
1637        Stmt::AttachElement(e) => {
1638            check_void_root(file, e, void_defs, resolution_by_range, out);
1639        }
1640        Stmt::Divert(_)
1641        | Stmt::TunnelCall(_)
1642        | Stmt::ThreadStart(_)
1643        | Stmt::Return(_)
1644        | Stmt::ExprStmt(_)
1645        | Stmt::EndOfLine
1646        | Stmt::EndElementRun => {}
1647    }
1648}
1649
1650fn check_void_content(
1651    file: FileId,
1652    content: &Content,
1653    void_defs: &BTreeSet<DefinitionId>,
1654    resolution_by_range: &BTreeMap<(u32, u32), DefinitionId>,
1655    out: &mut Vec<brink_ir::Diagnostic>,
1656) {
1657    for part in &content.parts {
1658        check_void_content_part(file, part, void_defs, resolution_by_range, out);
1659    }
1660}
1661
1662fn check_void_content_part(
1663    file: FileId,
1664    part: &ContentPart,
1665    void_defs: &BTreeSet<DefinitionId>,
1666    resolution_by_range: &BTreeMap<(u32, u32), DefinitionId>,
1667    out: &mut Vec<brink_ir::Diagnostic>,
1668) {
1669    match part {
1670        ContentPart::InlineConditional(c) => {
1671            for branch in &c.branches {
1672                check_void_block(file, &branch.body, void_defs, resolution_by_range, out);
1673            }
1674        }
1675        ContentPart::InlineSequence(s) => {
1676            for branch in &s.branches {
1677                check_void_block(file, &branch.body, void_defs, resolution_by_range, out);
1678            }
1679        }
1680        // A span can nest a conditional/sequence (§4.3).
1681        ContentPart::Span(span) => {
1682            for child in &span.children {
1683                check_void_content_part(file, child, void_defs, resolution_by_range, out);
1684            }
1685        }
1686        ContentPart::Interpolation(_)
1687        | ContentPart::Text(_)
1688        | ContentPart::Glue
1689        | ContentPart::Spring => {}
1690    }
1691}
1692
1693fn check_void_block_stmt(
1694    file: FileId,
1695    bs: &BlockStmt,
1696    void_defs: &BTreeSet<DefinitionId>,
1697    resolution_by_range: &BTreeMap<(u32, u32), DefinitionId>,
1698    out: &mut Vec<brink_ir::Diagnostic>,
1699) {
1700    match bs {
1701        BlockStmt::TempDecl(t) => {
1702            if let Some(value) = &t.value {
1703                check_void_root(file, value, void_defs, resolution_by_range, out);
1704            }
1705        }
1706        BlockStmt::Assignment(a) => {
1707            check_void_root(file, &a.value, void_defs, resolution_by_range, out);
1708        }
1709        BlockStmt::If(i) => check_void_if(file, i, void_defs, resolution_by_range, out),
1710        BlockStmt::While(w) => {
1711            for s in &w.body {
1712                check_void_block_stmt(file, s, void_defs, resolution_by_range, out);
1713            }
1714        }
1715        BlockStmt::For(f) => {
1716            for s in &f.body {
1717                check_void_block_stmt(file, s, void_defs, resolution_by_range, out);
1718            }
1719        }
1720        BlockStmt::Await(a) => {
1721            if let Some(cond) = &a.condition {
1722                check_void_root(file, cond, void_defs, resolution_by_range, out);
1723            }
1724        }
1725        BlockStmt::Return(_)
1726        | BlockStmt::ExprStmt(_)
1727        | BlockStmt::Break(_)
1728        | BlockStmt::Continue(_) => {}
1729    }
1730}
1731
1732fn check_void_if(
1733    file: FileId,
1734    i: &IfStmt,
1735    void_defs: &BTreeSet<DefinitionId>,
1736    resolution_by_range: &BTreeMap<(u32, u32), DefinitionId>,
1737    out: &mut Vec<brink_ir::Diagnostic>,
1738) {
1739    for s in &i.body {
1740        check_void_block_stmt(file, s, void_defs, resolution_by_range, out);
1741    }
1742    match &i.else_branch {
1743        Some(ElseBranch::ElseIf(inner)) => {
1744            check_void_if(file, inner, void_defs, resolution_by_range, out);
1745        }
1746        Some(ElseBranch::Else(stmts)) => {
1747            for s in stmts {
1748                check_void_block_stmt(file, s, void_defs, resolution_by_range, out);
1749            }
1750        }
1751        None => {}
1752    }
1753}
1754
1755/// If `expr`'s root is `Expr::Call(path, _)` resolving to a def in
1756/// `void_defs`, push `E067`. Anything else (a non-call root, or a call that
1757/// doesn't resolve to a known void def) is silently clean — this is a root-
1758/// position-only check, never a recursive expression walk (a void call
1759/// buried inside e.g. `1 + f()` is a type error `infer::body` would already
1760/// have caught as a non-numeric operand, not this diagnostic's job).
1761fn check_void_root(
1762    file: FileId,
1763    expr: &Expr,
1764    void_defs: &BTreeSet<DefinitionId>,
1765    resolution_by_range: &BTreeMap<(u32, u32), DefinitionId>,
1766    out: &mut Vec<brink_ir::Diagnostic>,
1767) {
1768    let Expr::Call(path, _) = expr else {
1769        return;
1770    };
1771    // `path.range` — the callee `Path`'s own whole span — is the exact key
1772    // the analyzer's `ResolvedRef::range` produced for this call site
1773    // (issue #1561; see that field's doc for the other three consumers
1774    // keying on the same contract). A narrowed range here would silently
1775    // stop finding a resolution and E067 would never fire.
1776    let Some(&def_id) = resolution_by_range.get(&range_key(path.range)) else {
1777        return;
1778    };
1779    if !void_defs.contains(&def_id) {
1780        return;
1781    }
1782    out.push(brink_ir::Diagnostic {
1783        file,
1784        range: path.range,
1785        message: format!(
1786            "`{}` returns void — its result cannot be assigned (docs/typed-mode-spec.md §3)",
1787            path_display(path)
1788        ),
1789        code: brink_ir::DiagnosticCode::E067,
1790    });
1791}
1792
1793/// Dotted display name for a call target's `Path` (e.g. `knot.stitch`).
1794fn path_display(path: &Path) -> String {
1795    path.segments
1796        .iter()
1797        .map(|s| s.text.as_str())
1798        .collect::<Vec<_>>()
1799        .join(".")
1800}
1801
1802/// A temp declaration's own name span plus its resolved ascription type
1803/// (`None` if unascribed or the ascription doesn't resolve — same "silent,
1804/// not binding" contract [`annotations::resolve`] documents).
1805struct TempDecl {
1806    range: TextRange,
1807    annotation_ty: Option<Ty>,
1808}
1809
1810/// Walk one def's body collecting every `~ temp name[: type] = expr`
1811/// declaration by bare name (last declaration wins on a shadowed name — this
1812/// is diagnostic-only positioning, not a binding scope resolution). Mirrors
1813/// `infer::body`'s and `dialect_gate`'s own recursive shapes: `Stmt`-level
1814/// nesting (`ChoiceSet`/`Conditional`/`Sequence`/`LabeledBlock`/inline
1815/// content) plus the closed T1b `~ { … }` `BlockStmt` tree, which needs its
1816/// own hand-recursion (see `dialect_gate`'s module doc on why).
1817///
1818/// Deliberately does **not** descend into an `Expr::Lambda` reachable from a
1819/// `TempDecl`/`Assignment`/`Return`/`ExprStmt`'s own expression, looking for
1820/// the lambda's own `~ temp`/`let` declarations (issue #1763, filed from the
1821/// #1749/#1750 wave retro — that pair is the *effect-row* instance of a
1822/// block-bodied lambda's `stmts` going unwalked; this is the *strict-mode
1823/// temp-ascription* instance, in a different function, with a different
1824/// consumer). This walker has exactly one call site: `check_def`'s loop
1825/// over `body_types.locals`, `InferPass`'s accumulated map of the
1826/// **enclosing** def's own locals. After #1750's frame-boundary fix,
1827/// `InferPass::infer_lambda` snapshots `locals` (plus `annotated` /
1828/// `return_ty` / `has_value_return` / `local_fn_origins`) before walking a
1829/// block-bodied lambda's own body — its `stmts` through `infer_block_stmt`
1830/// and, since #1789, its tail expression too — and restores the snapshot
1831/// wholesale afterward, so a name declared only
1832/// inside a lambda body can **never** end up as a key in the enclosing
1833/// def's `body_types.locals`. On an *unshadowed* name that just means a
1834/// naive `Expr::Lambda` arm here would populate `TempDecl` entries this
1835/// module's one consumer structurally cannot look up — a dead entry,
1836/// nothing more. But on a *shadowed* name (the enclosing def declares the
1837/// same bare name the lambda does), `collect_temps`'s own last-write-wins
1838/// insert (see this fn's doc above) means a naive `Expr::Lambda` arm would
1839/// not stay dead: it would overwrite the enclosing declaration's
1840/// `TempDecl` — both `range` and `annotation_ty` — with the lambda's,
1841/// silently exempting the outer temp from `E065` and mis-spanning any
1842/// `E066` that does fire. That shadowing collision, not the harmlessness
1843/// of a dead entry on the unshadowed case, is the load-bearing reason not
1844/// to descend. Pinned by
1845/// `native_shadowed_lambda_local_temp_does_not_exempt_enclosing_temp`
1846/// (the shadowed case, proving the enclosing temp still `E065`-escapes
1847/// despite the lambda-local ascription), below.
1848///
1849/// Issue #1770 has since given a lambda its own strict-checked frame —
1850/// but not by descending here. `InferPass::infer_lambda` records a
1851/// lambda's own params/body-declared temps into a wholly separate,
1852/// cumulative vector (`BodyTypes::lambda_escapes`,
1853/// [`crate::infer::LambdaEscapeSlot`]), re-emitted by `check_def` under
1854/// the enclosing def's own label — never merged into, and never read
1855/// back out of, `body_types.locals`. So the shadowing collision this doc
1856/// describes is still exactly as live a hazard for *this* function as it
1857/// ever was: a naive `Expr::Lambda` arm added directly to `collect_temps`
1858/// would still overwrite an enclosing shadowed name's `TempDecl` with the
1859/// lambda-local one. #1770 sidesteps the whole question rather than
1860/// answering it — don't read the existence of `lambda_escapes` as license
1861/// to add that arm here; the two mechanisms solve different problems
1862/// (this fn's one consumer keys off *bare name*, which is exactly what
1863/// breaks under shadowing, while `lambda_escapes` never keys off name at
1864/// all). See `native_lambda_local_temp_ascription_now_reaches_its_own_
1865/// escape_check`, below, for the now-visible unshadowed case this doc
1866/// used to pin here before #1770 gave it a home of its own.
1867///
1868/// See `docs/effects-spec.md` §4.1 (issue #1762) for the general
1869/// frame-scoped-vs-cumulative `InferPass` field rule this is the mirror
1870/// case of: `locals` is frame-scoped and must not leak a lambda-local
1871/// name out into the enclosing def, from either direction.
1872fn collect_temps(body: &Block, names: &annotations::TypeNames) -> BTreeMap<String, TempDecl> {
1873    let mut out = BTreeMap::new();
1874    collect_temps_block(body, names, &mut out);
1875    out
1876}
1877
1878fn collect_temps_block(
1879    block: &Block,
1880    names: &annotations::TypeNames,
1881    out: &mut BTreeMap<String, TempDecl>,
1882) {
1883    for stmt in &block.stmts {
1884        collect_temps_stmt(stmt, names, out);
1885    }
1886}
1887
1888fn collect_temps_stmt(
1889    stmt: &Stmt,
1890    names: &annotations::TypeNames,
1891    out: &mut BTreeMap<String, TempDecl>,
1892) {
1893    match stmt {
1894        // `t.value` (the initializer expression) is never inspected here —
1895        // only the declaration's own name/ascription. See `collect_temps`'s
1896        // doc comment for why a nested `Expr::Lambda` inside it is not
1897        // walked either.
1898        Stmt::TempDecl(t) => {
1899            let annotation_ty = t
1900                .annotation
1901                .as_ref()
1902                .and_then(|te| annotations::resolve(te, names));
1903            out.insert(
1904                t.name.text.clone(),
1905                TempDecl {
1906                    range: t.name.range,
1907                    annotation_ty,
1908                },
1909            );
1910        }
1911        Stmt::ChoiceSet(cs) => {
1912            for choice in &cs.choices {
1913                collect_temps_block(&choice.body, names, out);
1914                if let Some(c) = &choice.start_content {
1915                    collect_temps_content(c, names, out);
1916                }
1917                if let Some(c) = &choice.bracket_content {
1918                    collect_temps_content(c, names, out);
1919                }
1920                if let Some(c) = &choice.inner_content {
1921                    collect_temps_content(c, names, out);
1922                }
1923            }
1924            collect_temps_block(&cs.continuation, names, out);
1925        }
1926        Stmt::LabeledBlock(b) => collect_temps_block(b, names, out),
1927        Stmt::Conditional(c) => {
1928            for branch in &c.branches {
1929                collect_temps_block(&branch.body, names, out);
1930            }
1931        }
1932        Stmt::Sequence(s) => {
1933            for branch in &s.branches {
1934                collect_temps_block(&branch.body, names, out);
1935            }
1936        }
1937        Stmt::Content(c) => collect_temps_content(c, names, out),
1938        Stmt::LogicBlock(lb) => {
1939            for bs in &lb.stmts {
1940                collect_temps_block_stmt(bs, names, out);
1941            }
1942        }
1943        // An `await` condition is an expression — it declares no temps
1944        // (docs/flow-suspension-spec.md §3). `Assignment`/`Return`/
1945        // `ExprStmt`/`Await` each carry an expression too (any of which
1946        // could itself be, or embed, an `Expr::Lambda`) that is likewise
1947        // never inspected — see `collect_temps`'s doc comment.
1948        Stmt::Divert(_)
1949        | Stmt::TunnelCall(_)
1950        | Stmt::ThreadStart(_)
1951        | Stmt::Assignment(_)
1952        | Stmt::Return(_)
1953        | Stmt::ExprStmt(_)
1954        | Stmt::Await(_)
1955        | Stmt::EndOfLine
1956        | Stmt::AttachElement(_)
1957        | Stmt::EndElementRun => {}
1958    }
1959}
1960
1961fn collect_temps_content(
1962    content: &Content,
1963    names: &annotations::TypeNames,
1964    out: &mut BTreeMap<String, TempDecl>,
1965) {
1966    for part in &content.parts {
1967        collect_temps_content_part(part, names, out);
1968    }
1969}
1970
1971fn collect_temps_content_part(
1972    part: &ContentPart,
1973    names: &annotations::TypeNames,
1974    out: &mut BTreeMap<String, TempDecl>,
1975) {
1976    match part {
1977        ContentPart::InlineConditional(c) => {
1978            for branch in &c.branches {
1979                collect_temps_block(&branch.body, names, out);
1980            }
1981        }
1982        ContentPart::InlineSequence(s) => {
1983            for branch in &s.branches {
1984                collect_temps_block(&branch.body, names, out);
1985            }
1986        }
1987        // A span can nest a conditional/sequence (§4.3).
1988        ContentPart::Span(span) => {
1989            for child in &span.children {
1990                collect_temps_content_part(child, names, out);
1991            }
1992        }
1993        ContentPart::Interpolation(_)
1994        | ContentPart::Text(_)
1995        | ContentPart::Glue
1996        | ContentPart::Spring => {}
1997    }
1998}
1999
2000fn collect_temps_block_stmt(
2001    bs: &BlockStmt,
2002    names: &annotations::TypeNames,
2003    out: &mut BTreeMap<String, TempDecl>,
2004) {
2005    match bs {
2006        // `t.value` is never inspected — this is also the arm a `let g =
2007        // |x| …;` lambda-literal binding takes; see `collect_temps`'s doc
2008        // comment for why its body is not walked looking for the lambda's
2009        // own temps.
2010        BlockStmt::TempDecl(t) => {
2011            let annotation_ty = t
2012                .annotation
2013                .as_ref()
2014                .and_then(|te| annotations::resolve(te, names));
2015            out.insert(
2016                t.name.text.clone(),
2017                TempDecl {
2018                    range: t.name.range,
2019                    annotation_ty,
2020                },
2021            );
2022        }
2023        BlockStmt::If(i) => collect_temps_if(i, names, out),
2024        BlockStmt::While(w) => {
2025            for s in &w.body {
2026                collect_temps_block_stmt(s, names, out);
2027            }
2028        }
2029        BlockStmt::For(f) => {
2030            for s in &f.body {
2031                collect_temps_block_stmt(s, names, out);
2032            }
2033        }
2034        // Each of these carries an expression too (any of which could
2035        // itself be, or embed, an `Expr::Lambda`) that is likewise never
2036        // inspected — see `collect_temps`'s doc comment.
2037        BlockStmt::Assignment(_)
2038        | BlockStmt::Return(_)
2039        | BlockStmt::ExprStmt(_)
2040        | BlockStmt::Await(_)
2041        | BlockStmt::Break(_)
2042        | BlockStmt::Continue(_) => {}
2043    }
2044}
2045
2046fn collect_temps_if(
2047    i: &IfStmt,
2048    names: &annotations::TypeNames,
2049    out: &mut BTreeMap<String, TempDecl>,
2050) {
2051    for s in &i.body {
2052        collect_temps_block_stmt(s, names, out);
2053    }
2054    match &i.else_branch {
2055        Some(ElseBranch::ElseIf(inner)) => collect_temps_if(inner, names, out),
2056        Some(ElseBranch::Else(stmts)) => {
2057            for s in stmts {
2058                collect_temps_block_stmt(s, names, out);
2059            }
2060        }
2061        None => {}
2062    }
2063}
2064
2065#[cfg(test)]
2066mod tests {
2067    use super::*;
2068    use brink_ir::{Diagnostic, DiagnosticCode, ResolutionMap, hir::lower};
2069
2070    // ── resolve_type_policy: one test per (dialect × explicit/implicit)
2071    //    cell (issue #1127, ruled 2026-07-19) ──────────────────────────────
2072
2073    #[test]
2074    fn resolve_brink_implicit_defaults_strict() {
2075        assert_eq!(
2076            resolve_type_policy(crate::Dialect::Brink, None),
2077            TypePolicy::Strict
2078        );
2079    }
2080
2081    #[test]
2082    fn resolve_strict_ink_implicit_defaults_gradual() {
2083        assert_eq!(
2084            resolve_type_policy(crate::Dialect::StrictInk, None),
2085            TypePolicy::Gradual
2086        );
2087    }
2088
2089    #[test]
2090    fn resolve_brink_explicit_gradual_wins() {
2091        assert_eq!(
2092            resolve_type_policy(crate::Dialect::Brink, Some(TypePolicy::Gradual)),
2093            TypePolicy::Gradual
2094        );
2095    }
2096
2097    #[test]
2098    fn resolve_brink_explicit_strict_stays_strict() {
2099        assert_eq!(
2100            resolve_type_policy(crate::Dialect::Brink, Some(TypePolicy::Strict)),
2101            TypePolicy::Strict
2102        );
2103    }
2104
2105    #[test]
2106    fn resolve_strict_ink_explicit_gradual_stays_gradual() {
2107        assert_eq!(
2108            resolve_type_policy(crate::Dialect::StrictInk, Some(TypePolicy::Gradual)),
2109            TypePolicy::Gradual
2110        );
2111    }
2112
2113    #[test]
2114    fn resolve_strict_ink_explicit_strict_wins() {
2115        // The E064 config error is downstream (config_error); resolution
2116        // itself honors the explicit request.
2117        assert_eq!(
2118            resolve_type_policy(crate::Dialect::StrictInk, Some(TypePolicy::Strict)),
2119            TypePolicy::Strict
2120        );
2121    }
2122
2123    fn build(src: &str) -> (HirFile, SymbolIndex, ResolutionMap) {
2124        let parsed = brink_syntax::parse(src);
2125        let (hir, manifest, _diag) = lower(FileId(0), &parsed.tree());
2126        let (index, _diag) = crate::symbol_index(&[(FileId(0), &manifest)]);
2127        let (resolutions, _diag) =
2128            crate::resolve(FileId(0), &manifest, &index, &crate::ImportScope::default());
2129        (hir, (*index).clone(), (*resolutions).clone())
2130    }
2131
2132    fn codes(diags: &[Diagnostic]) -> Vec<DiagnosticCode> {
2133        let mut v: Vec<DiagnosticCode> = diags.iter().map(|d| d.code).collect();
2134        v.sort_by_key(|c| c.as_str());
2135        v
2136    }
2137
2138    // ── config_error ────────────────────────────────────────────────
2139
2140    #[test]
2141    fn config_error_fires_for_strict_ink_dialect() {
2142        let diag = config_error(crate::Dialect::StrictInk, Some(FileId(0)));
2143        assert!(diag.is_some());
2144        assert_eq!(diag.expect("checked above").code, DiagnosticCode::E064);
2145    }
2146
2147    #[test]
2148    fn config_error_is_none_for_brink_dialect() {
2149        assert!(config_error(crate::Dialect::Brink, Some(FileId(0))).is_none());
2150    }
2151
2152    #[test]
2153    fn config_error_is_none_with_no_files() {
2154        assert!(config_error(crate::Dialect::StrictInk, None).is_none());
2155    }
2156
2157    // ── strict_diagnostics: is_native decouples E064 (issue #1348) ────
2158    //
2159    // `dialect` is an ink-only axis (docs/t1b-surface-spec.md §1) — a native
2160    // `.brink` project has no dialect to be wrong about, so `config_error`
2161    // must never fire for one, regardless of `opts.dialect`'s value (a
2162    // native compile never sets it, and the default `StrictInk` is what used
2163    // to trip `E064` the instant `types = strict` was requested).
2164
2165    #[test]
2166    fn strict_diagnostics_is_native_true_never_fires_config_error() {
2167        // `dialect` left at its `StrictInk` default — the exact combination
2168        // that fires `E064` for an ink project — plus `types = strict`, the
2169        // B0.9 native strict-only posture (issue #1342).
2170        let (hir, index, res) = build("=== main ===\nHello.\n-> DONE\n");
2171        let opts = crate::AnalysisOptions {
2172            types: Some(TypePolicy::Strict),
2173            ..Default::default()
2174        };
2175        let diags = crate::strict_diagnostics(
2176            &[(FileId(0), &hir)],
2177            &index,
2178            &res,
2179            &opts,
2180            true,
2181            None,
2182            &BTreeMap::new(),
2183        );
2184        assert!(
2185            !diags.iter().any(|d| d.code == DiagnosticCode::E064),
2186            "native must never see the ink-only dialect config error: {diags:?}"
2187        );
2188    }
2189
2190    #[test]
2191    fn strict_diagnostics_is_native_true_still_runs_inference_checks() {
2192        // Skipping `config_error` must not skip the rest of strict mode —
2193        // an otherwise-escaping param must still `E065` for a native project.
2194        let (hir, index, res) = build("=== noop(x) ===\nHello.\n-> DONE\n");
2195        let opts = crate::AnalysisOptions {
2196            types: Some(TypePolicy::Strict),
2197            ..Default::default()
2198        };
2199        let diags = crate::strict_diagnostics(
2200            &[(FileId(0), &hir)],
2201            &index,
2202            &res,
2203            &opts,
2204            true,
2205            None,
2206            &BTreeMap::new(),
2207        );
2208        assert_eq!(diags.len(), 1, "{diags:?}");
2209        assert_eq!(diags[0].code, DiagnosticCode::E065);
2210    }
2211
2212    #[test]
2213    fn strict_diagnostics_is_native_false_unaffected_still_fires_config_error() {
2214        // The `is_native = false` (ink) path is byte-identical to before
2215        // this parameter existed — same `StrictInk` + `types = strict`
2216        // combination as the test above, still an `E064` config error.
2217        let (hir, index, res) = build("=== main ===\nHello.\n-> DONE\n");
2218        let opts = crate::AnalysisOptions {
2219            types: Some(TypePolicy::Strict),
2220            ..Default::default()
2221        };
2222        let diags = crate::strict_diagnostics(
2223            &[(FileId(0), &hir)],
2224            &index,
2225            &res,
2226            &opts,
2227            false,
2228            None,
2229            &BTreeMap::new(),
2230        );
2231        assert_eq!(diags.len(), 1, "{diags:?}");
2232        assert_eq!(diags[0].code, DiagnosticCode::E064);
2233    }
2234
2235    // ── native_strict_only_error (B0.9, issue #1342) ────────────────
2236
2237    #[test]
2238    fn native_strict_only_fires_for_explicit_gradual() {
2239        let diag = native_strict_only_error(FileId(0), Some(TypePolicy::Gradual));
2240        assert!(diag.is_some());
2241        assert_eq!(diag.expect("checked above").code, DiagnosticCode::E137);
2242    }
2243
2244    #[test]
2245    fn native_strict_only_is_none_for_explicit_strict() {
2246        assert!(native_strict_only_error(FileId(0), Some(TypePolicy::Strict)).is_none());
2247    }
2248
2249    #[test]
2250    fn native_strict_only_is_none_for_unset_types() {
2251        // No explicit knob turned — the dialect-defaulted resolution (not
2252        // this gate's concern, see the function doc) governs instead.
2253        assert!(native_strict_only_error(FileId(0), None).is_none());
2254    }
2255
2256    // ── check(): Unknown-escape ────────────────────────────────────
2257
2258    #[test]
2259    fn unused_param_escapes_as_unknown() {
2260        let (hir, index, res) = build("=== noop(x) ===\nHello.\n-> DONE\n");
2261        let inference =
2262            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
2263        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
2264        assert_eq!(diags.len(), 1, "{diags:?}");
2265        assert_eq!(diags[0].code, DiagnosticCode::E065);
2266        assert!(diags[0].message.contains('x'));
2267    }
2268
2269    #[test]
2270    fn annotated_unused_param_is_exempt_from_unknown_escape() {
2271        let (hir, index, res) = build("=== noop(x: int) ===\nHello.\n-> DONE\n");
2272        let inference =
2273            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
2274        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
2275        assert!(diags.is_empty(), "annotation supplies the type: {diags:?}");
2276    }
2277
2278    /// T1d-2 (docs/t1d-spec.md §3): a `Handle<K>`-annotated, otherwise-unused
2279    /// param is exempt from `E065` the same way any other resolvable
2280    /// annotation is — "strict kind-checking via existing TM-3 machinery",
2281    /// reusing the annotation-firewall exemption path, no new code needed.
2282    /// Reachable only when the manifest declaring `K` is registered — with
2283    /// none registered, the annotation doesn't resolve and the slot escapes
2284    /// as `Unknown` exactly like an unrecognized type name would.
2285    #[test]
2286    fn annotated_handle_param_is_exempt_from_unknown_escape_when_kind_is_registered() {
2287        let (hir, index, res) = build("=== noop(x: Handle<AudioInstance>) ===\nHello.\n-> DONE\n");
2288        let inference =
2289            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
2290        let manifest = brink_ir::HostManifest {
2291            markup: Vec::new(),
2292            types: vec![brink_ir::SemanticTypeDef {
2293                name: "AudioInstance".to_string(),
2294                base: brink_ir::BaseType::Handle,
2295                constraint: None,
2296                values: None,
2297                widget: None,
2298            }],
2299            ..Default::default()
2300        };
2301        let diags = check(
2302            &[(FileId(0), &hir)],
2303            &index,
2304            &inference,
2305            &res,
2306            Some(&manifest),
2307        );
2308        assert!(diags.is_empty(), "annotation supplies the type: {diags:?}");
2309    }
2310
2311    // ── NG-A/NG-B: the annotation firewall reaches the NATIVE frontend ──
2312    //
2313    // Issues #1487/#1488. Native is strict-only (E137, #1342), so before
2314    // the `: type` grammar existed every escaping native param/binding was
2315    // structurally condemned to `E065` — this module's annotation firewall
2316    // (`check_def`'s `p.annotation` / `collect_temps`' `annotation_ty`
2317    // exemption, which exempts an `Unknown` escape but never a
2318    // `Conflicted` one) was unreachable from a `.brink` file. These prove
2319    // it now fires, through the same `check` entry point the ink fixtures
2320    // above use — the annotations land in the same `hir::TypeExpr` slots.
2321
2322    /// Native-lowered `(HirFile, SymbolIndex, ResolutionMap)`, the native
2323    /// counterpart of [`build`] (which parses through `brink_syntax`, the
2324    /// ink/brink-extension frontend). Mirrors `coalesce`'s own
2325    /// `build_native`.
2326    fn build_native(src: &str) -> (HirFile, SymbolIndex, ResolutionMap) {
2327        let parsed = brink_syntax_native::parse(src);
2328        assert!(
2329            parsed.errors().is_empty(),
2330            "fixture must parse cleanly: {:?}",
2331            parsed.errors()
2332        );
2333        let tree = parsed.tree();
2334        let (hir, manifest, _diag) = brink_ir::hir::lower_native::lower(FileId(0), &tree);
2335        let (index, _diag) = crate::symbol_index(&[(FileId(0), &manifest)]);
2336        let (resolutions, _diag) =
2337            crate::resolve(FileId(0), &manifest, &index, &crate::ImportScope::default());
2338        (hir, (*index).clone(), (*resolutions).clone())
2339    }
2340
2341    fn native_strict_diags(src: &str) -> Vec<Diagnostic> {
2342        let (hir, index, res) = build_native(src);
2343        let inference =
2344            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
2345        check(&[(FileId(0), &hir)], &index, &inference, &res, None)
2346    }
2347
2348    #[test]
2349    fn native_unannotated_param_escapes_as_unknown() {
2350        // The baseline the exemption is measured against — without it the
2351        // next test would pass with the firewall deleted.
2352        let diags = native_strict_diags("flow noop(x) {\n  Hello.\n}\n");
2353        assert_eq!(diags.len(), 1, "{diags:?}");
2354        assert_eq!(diags[0].code, DiagnosticCode::E065);
2355    }
2356
2357    #[test]
2358    fn native_annotated_param_is_exempt_from_unknown_escape() {
2359        let diags = native_strict_diags("flow noop(x: int) {\n  Hello.\n}\n");
2360        assert!(
2361            diags.is_empty(),
2362            "the `: int` annotation supplies the type: {diags:?}"
2363        );
2364    }
2365
2366    // ── Issue #1912(a): handing an annotated param straight back out ──
2367    //
2368    // `infer::body::InferPass::infer_return` applies `or_own_annotation` to
2369    // the returned value, so `return <annotated param>` exports the
2370    // parameter's declared type instead of `Unknown`. Filed against
2371    // `content` (#1846 gave it a resolvable `Ty`; #1882's native strict
2372    // sweep caught the corpus row) but never `content`-specific — the
2373    // second test below is the proof of that.
2374
2375    #[test]
2376    fn native_returning_a_content_param_takes_its_annotated_type() {
2377        // Issue #1912's own reduction, both halves: the annotated-return
2378        // twin was already clean, the bare one reported `E065` on a return
2379        // type that is *exactly* the annotated parameter type.
2380        let bare = native_strict_diags("fn passthru(t: content) {\n  return t;\n}\n");
2381        assert!(
2382            bare.is_empty(),
2383            "`t: content` supplies the return type: {bare:?}"
2384        );
2385        let annotated = native_strict_diags("fn passthru(t: content): content {\n  return t;\n}\n");
2386        assert!(
2387            annotated.is_empty(),
2388            "the annotated twin stays clean: {annotated:?}"
2389        );
2390    }
2391
2392    #[test]
2393    fn native_returning_an_annotated_param_is_not_content_specific() {
2394        // Issue #1912 framed the gap as a `content` one; it was general to
2395        // every resolvable annotation. All four leaf spellings, so a fix
2396        // that only special-cased `Ty::Content` would fail here.
2397        for ty in ["int", "float", "bool", "string"] {
2398            let src = format!("fn passthru(t: {ty}) {{\n  return t;\n}}\n");
2399            let diags = native_strict_diags(&src);
2400            assert!(
2401                diags.is_empty(),
2402                "`t: {ty}` supplies the return type: {diags:?}"
2403            );
2404        }
2405    }
2406
2407    #[test]
2408    fn native_a_body_use_contradicting_the_annotation_still_reports_e063() {
2409        // The TM-2 firewall #1912's fix must not dissolve: `or_own_annotation`
2410        // overlays an `Unknown` only, so a param the body *does* constrain
2411        // keeps exporting its own independent derivation and
2412        // `annotations::mismatches` still has two things to compare.
2413        let diags = native_strict_diags("fn f(a: int) {\n  return a + \"x\";\n}\n");
2414        assert!(
2415            diags.iter().any(|d| d.code == DiagnosticCode::E063),
2416            "a body use disagreeing with the annotation still reports E063: {diags:?}"
2417        );
2418    }
2419
2420    #[test]
2421    fn native_returning_a_param_that_disagrees_with_the_return_annotation_reports_e063() {
2422        // The other side of the same coin, and a diagnostic that could not
2423        // fire before #1912: the body's return type used to escape as
2424        // `Unknown` and get overlaid by the *return* annotation, so a
2425        // handing-through that contradicts the declared return was silent.
2426        let diags = native_strict_diags("fn f(t: content): string {\n  return t;\n}\n");
2427        assert!(
2428            diags.iter().any(|d| d.code == DiagnosticCode::E063),
2429            "returning a `content` param from a `: string` fn disagrees: {diags:?}"
2430        );
2431    }
2432
2433    #[test]
2434    fn native_unresolvable_param_annotation_still_escapes() {
2435        // The firewall exempts a *resolvable* annotation only
2436        // (`annotations::resolve`) — an unrecognized name supplies nothing.
2437        let diags = native_strict_diags("flow noop(x: Nonesuch) {\n  Hello.\n}\n");
2438        assert!(
2439            diags.iter().any(|d| d.code == DiagnosticCode::E065),
2440            "an unresolvable annotation must not exempt the slot: {diags:?}"
2441        );
2442    }
2443
2444    #[test]
2445    fn native_annotated_let_is_exempt_from_unknown_escape() {
2446        // NG-B's binding half: an ascribed `let` inside a code-ground `fn`
2447        // body reaches `collect_temps`' `annotation_ty` exemption.
2448        let bare = native_strict_diags("fn f(n: int): int {\n  let t;\n  return n;\n}\n");
2449        assert!(
2450            bare.iter().any(|d| d.code == DiagnosticCode::E065),
2451            "an unannotated, uninferable `let` escapes: {bare:?}"
2452        );
2453        let annotated =
2454            native_strict_diags("fn f(n: int): int {\n  let t: string;\n  return n;\n}\n");
2455        assert!(
2456            annotated.is_empty(),
2457            "the `: string` ascription supplies the type: {annotated:?}"
2458        );
2459    }
2460
2461    /// Issue #1770 (closing the gap #1763 pinned as the then-deliberate
2462    /// interim posture): a temp declared *inside* a lambda's own block body
2463    /// now gets its own per-lambda escape-check frame
2464    /// ([`crate::infer::LambdaEscapeSlot`]), populated by
2465    /// `InferPass::infer_lambda` and re-emitted by `check_def` under the
2466    /// enclosing def's own label — so it is no longer invisible just
2467    /// because it never reaches the *enclosing* def's own
2468    /// `body_types.locals` (`InferPass::infer_lambda`'s #1750 snapshot/
2469    /// restore of `locals` still keeps it out of *that* map; this is a
2470    /// wholly separate, cumulative map fed straight from the same walk).
2471    ///
2472    /// Before this fix (see the git history of this test, formerly
2473    /// `native_lambda_local_temp_ascription_is_invisible_to_enclosing_
2474    /// escape_check`): the unannotated and the ascribed lambda-local `let
2475    /// t` below produced the *identical* (empty) diagnostic set — the
2476    /// ascription changed nothing observable. Now they genuinely differ,
2477    /// proving the ascription firewall reaches a lambda's own temps exactly
2478    /// like it already does a top-level one
2479    /// (`native_annotated_let_is_exempt_from_unknown_escape`, the same `let
2480    /// t;` shape one scope out).
2481    #[test]
2482    fn native_lambda_local_temp_ascription_now_reaches_its_own_escape_check() {
2483        let unannotated = native_strict_diags(
2484            "fn f(n: int): int {\n  let g = |x: int|: int {\n    let t;\n    x\n  };\n  return n;\n}\n",
2485        );
2486        assert_eq!(
2487            unannotated.len(),
2488            1,
2489            "the lambda's own unannotated `let t` (never used, so genuinely \
2490             `Unknown`) now escapes in its own right: {unannotated:?}"
2491        );
2492        assert_eq!(unannotated[0].code, DiagnosticCode::E065);
2493        assert!(
2494            unannotated[0].message.contains("lambda temp `t`"),
2495            "{unannotated:?}"
2496        );
2497
2498        let ascribed = native_strict_diags(
2499            "fn f(n: int): int {\n  let g = |x: int|: int {\n    let t: string;\n    x\n  };\n  return n;\n}\n",
2500        );
2501        assert!(
2502            ascribed.is_empty(),
2503            "the `: string` ascription now supplies the type, exempting the \
2504             lambda's own temp exactly like a top-level one: {ascribed:?}"
2505        );
2506    }
2507
2508    /// Guards the shadowing hazard the `collect_temps` doc comment calls
2509    /// out: on a shadowed name, `collect_temps`'s last-write-wins insert
2510    /// means a naive `Expr::Lambda` arm added directly to it would
2511    /// overwrite the *enclosing* `let t;`'s `TempDecl` with the
2512    /// lambda-local `let t: string;`'s ascribed one, silently exempting the
2513    /// outer temp from `E065`. Issue #1770 gives the lambda's own `t` a
2514    /// genuine escape-check frame now (a separate, `LambdaEscapeSlot`-based
2515    /// map fed straight from `InferPass::infer_lambda`'s own walk — see
2516    /// that fact's own doc for why this sidesteps the collision entirely,
2517    /// never touching `collect_temps`), so this fixture's own hazard
2518    /// (`collect_temps_stmt`/`collect_temps_block_stmt` growing a naive
2519    /// `Expr::Lambda` arm) remains exactly as un-triggered as before. Still
2520    /// pins the enclosing temp's own escape, now expected to be the *only*
2521    /// diagnostic (the lambda's own `t: string` is separately, correctly
2522    /// exempt by its own ascription). (Shadowing itself is legal here:
2523    /// `check_capture_writes`
2524    /// (`crates/internal/brink-ir/src/hir/lower_native/lambda.rs`) fires
2525    /// `E156` only on writes to *captured* outers, never on a lambda-local
2526    /// re-declaration of the same name.)
2527    #[test]
2528    fn native_shadowed_lambda_local_temp_does_not_exempt_enclosing_temp() {
2529        let diags = native_strict_diags(
2530            "fn f(n: int): int {\n  let t;\n  let g = |x: int|: int {\n    let t: string;\n    x\n  };\n  return n;\n}\n",
2531        );
2532        assert_eq!(
2533            diags.len(),
2534            1,
2535            "the enclosing, unannotated `let t;` must still escape as E065 \
2536             even though a lambda-local `let t: string;` shadows the same \
2537             bare name with its own ascription — a naive `Expr::Lambda` arm \
2538             in `collect_temps` would overwrite the enclosing `TempDecl` \
2539             and silently swallow this; the lambda's own `t` is separately \
2540             exempt by its own ascription, so nothing else should appear: \
2541             {diags:?}"
2542        );
2543        assert_eq!(diags[0].code, DiagnosticCode::E065);
2544    }
2545
2546    /// Issue #1770's `E066` (Conflicted-escape) half — the sibling of
2547    /// [`native_lambda_local_temp_ascription_now_reaches_its_own_escape_check`],
2548    /// which only pins the `E065` (Unknown-escape) half. `t` is written as
2549    /// an `int` then reassigned a `string` inside the lambda's own block
2550    /// body, a genuine same-type disagreement (`unify(int, string) ==
2551    /// Conflicted`, the #627 lattice) local to the lambda's own frame —
2552    /// never observable at the top level at all, since `t` is declared and
2553    /// used entirely inside `g`'s body.
2554    #[test]
2555    fn native_lambda_local_temp_with_conflicting_uses_reports_e066() {
2556        let diags = native_strict_diags(
2557            "fn f(n: int): int {\n  let g = |x: int|: int {\n    let t = 1;\n    t = \"oops\";\n    x\n  };\n  return n;\n}\n",
2558        );
2559        assert_eq!(
2560            diags.len(),
2561            1,
2562            "the lambda's own `t` genuinely disagrees with itself (`int` \
2563             then `string`) and must escape as E066, not merely go \
2564             unreported the way a lambda-local temp did before #1770: \
2565             {diags:?}"
2566        );
2567        assert_eq!(diags[0].code, DiagnosticCode::E066);
2568        assert!(diags[0].message.contains("lambda temp `t`"), "{diags:?}");
2569    }
2570
2571    /// Review finding on #1770: a param name the lambda's own body
2572    /// re-binds (`|t: int| { let t = 1; t = "oops"; t }`) must be reported
2573    /// as the *rebound local's* own escape, never misattributed to the
2574    /// annotated parameter of the same spelling. Before this fix, the
2575    /// params governance loop read `self.locals["t"]` — by then the
2576    /// rebound local's accumulated type, `Conflicted` here, not the
2577    /// param's — straight into a `LambdaEscapeSlot` labeled
2578    /// `"lambda parameter `t`"`, blaming the annotated param for a
2579    /// contradiction entirely internal to the fresh local that shadows it,
2580    /// while the body-declared-temps loop silently skipped the name
2581    /// entirely (see `LambdaEscapeSlot::annotated`'s doc and
2582    /// `InferPass::infer_lambda`'s two governance-loop comments). The
2583    /// temps loop now owns this name instead, so the only lambda-frame
2584    /// row is a `"lambda temp `t`"` one and no `"lambda parameter `t`"`
2585    /// row appears. The enclosing `let g = …` temp also escapes as
2586    /// Conflicted in its own right — `g`'s inferred `fn(Conflicted):
2587    /// Conflicted` type recursively classifies as Conflicted too
2588    /// (`classify`'s own `Ty::Fn` arm, the same shape
2589    /// `native_nested_lambda_inside_lambda_gets_its_own_escape_frame_too`
2590    /// exercises for Unknown) — a real, independent escape, not a
2591    /// duplicate.
2592    #[test]
2593    fn native_lambda_rebound_param_escape_is_attributed_to_the_temp_not_the_param() {
2594        let diags = native_strict_diags(
2595            "fn f(n: int): int {\n  let g = |t: int| {\n    let t = 1;\n    t = \"oops\";\n    t\n  };\n  return n;\n}\n",
2596        );
2597        assert_eq!(
2598            diags.len(),
2599            2,
2600            "the rebound local `t`'s own int/string contradiction escapes \
2601             at its own lambda-frame slot, and `g`'s own inferred \
2602             fn(Conflicted): Conflicted type recursively escapes too: \
2603             {diags:?}"
2604        );
2605        assert!(diags.iter().all(|d| d.code == DiagnosticCode::E066));
2606        assert!(
2607            diags.iter().any(|d| d.message.contains("lambda temp `t`")),
2608            "must be attributed to the rebound local, not the annotated \
2609             parameter of the same name: {diags:?}"
2610        );
2611        assert!(
2612            diags.iter().any(|d| d.message.contains("temp `g`")),
2613            "{diags:?}"
2614        );
2615        assert!(
2616            diags
2617                .iter()
2618                .all(|d| !d.message.contains("lambda parameter `t`")),
2619            "the annotated parameter `t` must never be blamed for a \
2620             contradiction entirely internal to the local that shadows it: \
2621             {diags:?}"
2622        );
2623    }
2624
2625    /// Issue #1770's own doc on [`crate::infer::LambdaEscapeSlot`]: a lambda
2626    /// nested inside another lambda's own body gets its **own**, separate
2627    /// frame — its escape slots are folded into the same flat, cumulative
2628    /// vector as the outer lambda's, not merged into (or lost inside) the
2629    /// outer lambda's own frame. Two diagnostics prove two independent
2630    /// things fired: `h`'s own unannotated, unused param `y` is only
2631    /// reachable by recursing into `g`'s own nested lambda `h` (proving the
2632    /// per-lambda walk genuinely recurses rather than stopping at the
2633    /// first lambda it finds); `g`'s own temp `h` *also* escapes, because
2634    /// `h`'s inferred `fn(Unknown): Unknown` type recursively classifies as
2635    /// `Unknown` too (`classify`'s own `Ty::Fn` arm) — a real, independent
2636    /// escape at `g`'s own frame, not a duplicate of `h`'s.
2637    #[test]
2638    fn native_nested_lambda_inside_lambda_gets_its_own_escape_frame_too() {
2639        let diags = native_strict_diags(
2640            "fn f(n: int): int {\n  let g = |x: int|: int {\n    let h = |y| y;\n    x\n  };\n  return n;\n}\n",
2641        );
2642        assert_eq!(
2643            diags.len(),
2644            2,
2645            "`h`'s own param `y` (only reachable by recursing into `g`'s \
2646             nested lambda) and `g`'s own temp `h` (whose `fn(Unknown): \
2647             Unknown` type itself classifies as Unknown) are two \
2648             independent escapes: {diags:?}"
2649        );
2650        assert!(diags.iter().all(|d| d.code == DiagnosticCode::E065));
2651        assert!(
2652            diags
2653                .iter()
2654                .any(|d| d.message.contains("lambda parameter `y`")),
2655            "{diags:?}"
2656        );
2657        assert!(
2658            diags.iter().any(|d| d.message.contains("lambda temp `h`")),
2659            "{diags:?}"
2660        );
2661    }
2662
2663    /// Issue #1789, the **read** direction of the tail-ordering bug: a
2664    /// block-bodied lambda's tail expression must be inferred while the
2665    /// lambda's own `locals` frame is still live, so a temp declared by the
2666    /// lambda's own `stmts` is visible to it.
2667    ///
2668    /// `h` is a lambda-local `fn(int): int` referenced *only* in tail
2669    /// position. Before the fix, `infer_lambda` walked the tail after
2670    /// restoring the enclosing def's `locals`, so `ty_of_def` (which keys
2671    /// `locals` by bare name) found nothing and typed the callee `Unknown`
2672    /// — `infer_value_call`'s `E063` arity check is skipped entirely on an
2673    /// `Unknown` callee, so the over-applied `h(1, 2)` was never checked
2674    /// for arity at all. A spurious `E065` Unknown-escape fired in its
2675    /// place instead — the wrong diagnostic, not silence.
2676    ///
2677    /// The `stmt_position` half is the discriminator: the identical
2678    /// over-application written as a `;`-terminated statement inside the
2679    /// same block has always been caught (it is walked inside the frame,
2680    /// per #1750), so this pins the *tail* as the thing that was broken
2681    /// rather than the arity check generally.
2682    #[test]
2683    fn native_lambda_tail_sees_its_own_block_locals() {
2684        let stmt_position = native_strict_diags(
2685            "fn f(n: int): int {\n  let g = |x: int|: int {\n    let h = |y: int|: int { y };\n    h(1, 2);\n    x\n  };\n  return n;\n}\n",
2686        );
2687        assert!(
2688            stmt_position.iter().any(|d| d.code == DiagnosticCode::E063),
2689            "baseline: an over-applied call to a lambda-local fn temp in \
2690             *statement* position is inside the #1750 frame window and has \
2691             always been checked: {stmt_position:?}"
2692        );
2693
2694        let tail_position = native_strict_diags(
2695            "fn f(n: int): int {\n  let g = |x: int|: int {\n    let h = |y: int|: int { y };\n    h(1, 2)\n  };\n  return n;\n}\n",
2696        );
2697        assert!(
2698            tail_position.iter().any(|d| d.code == DiagnosticCode::E063),
2699            "the very same over-application in *tail* position must be \
2700             checked too — the tail is the block's value position and reads \
2701             the locals its own statements bound (#1789): {tail_position:?}"
2702        );
2703    }
2704
2705    /// Issue #1789, the **write** direction — the leak #1750 closed for a
2706    /// lambda's `stmts` but left open for its tail.
2707    ///
2708    /// `observe` (see `infer::body`) keys `locals` by bare name, so a use in
2709    /// argument position unifies the parameter's type into whatever local
2710    /// carries that name *right now*. With the tail walked after the
2711    /// restore, the lambda's own `let t = "hi"` was gone and `takes_string(t)`
2712    /// unified `string` into the **enclosing** `f`'s `let t = 1` —
2713    /// `unify(int, string) == Conflicted` — reporting a spurious `E066` on a
2714    /// temp `f`'s own body never misuses. A false positive on user code.
2715    ///
2716    /// The `stmt_position` half is again the discriminator: the same
2717    /// argument-position use written as a statement never leaked, because
2718    /// #1750's snapshot/restore already wrapped it.
2719    #[test]
2720    fn native_lambda_tail_does_not_corrupt_a_shadowed_enclosing_local() {
2721        const TAKES_STRING: &str = "fn takes_string(s: string): string {\n  return s;\n}\n";
2722
2723        let stmt_position = native_strict_diags(&format!(
2724            "{TAKES_STRING}fn f(n: int): int {{\n  let t = 1;\n  let g = |x: int|: int {{\n    let t = \"hi\";\n    takes_string(t);\n    x\n  }};\n  return n;\n}}\n"
2725        ));
2726        assert!(
2727            stmt_position.is_empty(),
2728            "baseline: a lambda-local `t` used in argument position from a \
2729             *statement* is confined by #1750's snapshot/restore, so the \
2730             enclosing `let t = 1` stays `int`: {stmt_position:?}"
2731        );
2732
2733        let tail_position = native_strict_diags(&format!(
2734            "{TAKES_STRING}fn f(n: int): int {{\n  let t = 1;\n  let g = |x: int|: string {{\n    let t = \"hi\";\n    takes_string(t)\n  }};\n  return n;\n}}\n"
2735        ));
2736        assert!(
2737            tail_position.is_empty(),
2738            "the same use in *tail* position must be confined the same way — \
2739             before #1789 it unified `string` into the enclosing `f`'s own \
2740             `t: int` and reported a spurious E066 Conflicted-escape on it: \
2741             {tail_position:?}"
2742        );
2743    }
2744
2745    /// Issue #1789, a third direction discovered during review: opening the
2746    /// frame *around* the tail (not just restoring after it) also changes
2747    /// where `observe` lands for a *captured* (not shadowed) enclosing
2748    /// temp used from tail position — `f`'s own `let c;` no longer narrows
2749    /// from a use inside `g`'s tail, because that use's `observe` now runs
2750    /// and is undone inside the lambda's frame before `f`'s frame sees it.
2751    ///
2752    /// This is not a regression: the statement-position twin already
2753    /// reported the same `E065` on both sides of this PR (`c` is
2754    /// unannotated and untouched at every one of `f`'s own use sites
2755    /// either way, per #1750), so the tail case is now merely consistent
2756    /// with it rather than an outlier. It reaches
2757    /// `types = strict` diagnostics same as the other two directions, so
2758    /// it is pinned here rather than left as an incidental side effect.
2759    #[test]
2760    fn native_lambda_tail_capture_use_no_longer_narrows_enclosing_capture() {
2761        const TAKES_STRING: &str = "fn takes_string(s: string): string {\n  return s;\n}\n";
2762
2763        let stmt_position = native_strict_diags(&format!(
2764            "{TAKES_STRING}fn f(n: int): int {{\n  let c;\n  let g = ||: int {{ takes_string(c); 1 }};\n  return n;\n}}\n"
2765        ));
2766        assert!(
2767            stmt_position.iter().any(|d| d.code == DiagnosticCode::E065),
2768            "baseline: `f`'s own unannotated `let c;` still E065-escapes \
2769             when the capturing use is in *statement* position, on both \
2770             sides of #1789 — the use is never enough to narrow it: \
2771             {stmt_position:?}"
2772        );
2773
2774        let tail_position = native_strict_diags(&format!(
2775            "{TAKES_STRING}fn f(n: int): int {{\n  let c;\n  let g = ||: string {{ takes_string(c) }};\n  return n;\n}}\n"
2776        ));
2777        assert!(
2778            tail_position.iter().any(|d| d.code == DiagnosticCode::E065),
2779            "the same capturing use from *tail* position must escape the \
2780             same way — before #1789 the tail's `observe` ran against \
2781             whatever frame was live *after* the restore and could narrow \
2782             `f`'s own `c`; opening the frame around the tail keys that \
2783             `observe` to the lambda's own (discarded) frame instead, so \
2784             `c` is left exactly as unannotated as the statement-position \
2785             twin: {tail_position:?}"
2786        );
2787    }
2788
2789    // ── issue #1910: pure verb results and lambda-bound locals ────────
2790    //
2791    // `infer::body::InferPass::infer_lambda` used to walk a lambda's body
2792    // purely for its side effects and then throw away everything it
2793    // learned, rebuilding the lambda's own `Ty::Fn(params, ret, _)` from
2794    // *written* annotations alone — `Unknown` for every unannotated param,
2795    // `Unknown` for an unannotated return regardless of what the body
2796    // actually computed. That made a pure verb's inline callback
2797    // (`map`/`filter`/`fold`/`filter_map`/`map_each`) and a lambda literal
2798    // bound straight to a local escape strict inference as `Unknown` even
2799    // when the body unambiguously pinned the type.
2800
2801    #[test]
2802    fn native_map_result_infers_from_unannotated_lambda_body() {
2803        let diags = native_strict_diags(
2804            "fn doubled() {\n  let items = [1, 2, 3];\n  return map(items, |x| x * 2);\n}\n",
2805        );
2806        assert!(
2807            diags.is_empty(),
2808            "`x * 2` pins `x` (and so `map`'s result) to `int` from the \
2809             callback's own body alone, with no surrounding annotation: \
2810             {diags:?}"
2811        );
2812    }
2813
2814    #[test]
2815    fn native_fold_result_falls_back_to_the_seed_when_the_callback_body_is_unconstrained() {
2816        let diags = native_strict_diags(
2817            "fn total() {\n  let items = [1, 2, 3, 4];\n  return fold(items, 0, |acc, x| acc + x);\n}\n",
2818        );
2819        // Issue #1770: `acc`/`x` now get their own per-lambda escape-check
2820        // frame, and neither is pinned by the callback's own body (`acc + x`
2821        // joins two `Unknown`s) — so both correctly escape as `E065` in
2822        // their own right. What this test still pins is `fold`'s *own*
2823        // result: it must fall back to the seed `0`'s `int` rather than
2824        // itself escaping as a third, redundant diagnostic on `total`'s own
2825        // return type — the absence of any such row below is that proof.
2826        assert_eq!(
2827            diags.len(),
2828            2,
2829            "only the lambda's own two unconstrained params should escape — \
2830             `total`'s own return type must still fall back cleanly to the \
2831             seed's `int`: {diags:?}"
2832        );
2833        assert!(
2834            diags.iter().all(|d| d.code == DiagnosticCode::E065
2835                && (d.message.contains("lambda parameter `acc`")
2836                    || d.message.contains("lambda parameter `x`"))),
2837            "{diags:?}"
2838        );
2839    }
2840
2841    #[test]
2842    fn native_fold_still_reports_conflicted_when_the_callback_body_genuinely_conflicts() {
2843        // The seed fallback above must not paper over a genuine conflict —
2844        // `fold`'s arm only falls back to the seed when the callback's own
2845        // return is `Unknown`, never when it is `Conflicted` (real
2846        // information: the body really did observe two disagreeing types).
2847        // `-`, not `+`: issue #1911 (landed on `main` after this fixture was
2848        // first written) rules `string + int`/`string + float` legal display
2849        // concatenation, typing to `string` rather than `Conflicted` — `-`
2850        // has no such carve-out (`is_string_numeric_concat` is scoped to
2851        // `Add` only), so it still exercises a genuine same-type mismatch.
2852        let diags = native_strict_diags(
2853            "fn fold_conflicted() {\n  let items = [1, 2, 3];\n  return fold(items, 0, |a, b| {\n    let t = a + 1;\n    let t2 = a - \"oops\";\n    t2\n  });\n}\n",
2854        );
2855        assert!(
2856            diags.iter().any(|d| d.code == DiagnosticCode::E066),
2857            "`a` is joined against `int` (`a + 1`) and then `string` \
2858             (`a - \"oops\"`) inside the callback's own body — a genuine \
2859             conflict that must surface as E066, not be silently replaced \
2860             by the seed's `int`: {diags:?}"
2861        );
2862    }
2863
2864    #[test]
2865    fn native_verb_result_bound_to_a_let_is_not_unknown() {
2866        let diags = native_strict_diags(
2867            "fn let_map_then_len() {\n  let items = [1, 2, 3];\n  let out = map(items, |x| x * 2);\n  return len(out);\n}\n",
2868        );
2869        assert!(
2870            diags.is_empty(),
2871            "`out`'s type comes from `map`'s own now-concrete result, not \
2872             just the return position — a genuinely intermediate binding \
2873             must be just as clean: {diags:?}"
2874        );
2875    }
2876
2877    #[test]
2878    fn native_block_bodied_lambda_return_feeds_the_verb_result_too() {
2879        // The block-bodied twin of `native_map_result_infers_from_
2880        // unannotated_lambda_body`: the value comes from an internal
2881        // `return`, not the block's tail — `LambdaBody::Block`'s own doc:
2882        // "return leaves the lambda". Before #1910's `return_ty` reset this
2883        // read `self.return_ty` contaminated by whatever the *enclosing*
2884        // def's own return_ty already held, so `positives` (an `if`/`return`
2885        // shaped `filter_map` callback, `tests/tier1-native/lambda-verbs/
2886        // story.brink`) needed both fixes to resolve.
2887        let diags = native_strict_diags(
2888            "fn ret_from_block_lambda() {\n  let items = [1, 2, 3];\n  return map(items, |x| {\n    return x * 3;\n  });\n}\n",
2889        );
2890        assert!(
2891            diags.is_empty(),
2892            "the callback's `return x * 3;` pins its own return type to \
2893             `int` exactly like a trailing tail expression would: {diags:?}"
2894        );
2895    }
2896
2897    #[test]
2898    fn native_lambda_bound_local_takes_its_own_fn_type() {
2899        let diags = native_strict_diags(
2900            "fn lambda_let(): int {\n  let f = |x| x + 1;\n  return f(1);\n}\n",
2901        );
2902        assert!(
2903            diags.is_empty(),
2904            "`f`'s own inferred type is `fn(int): int` (from `x + 1`'s body \
2905             alone), not `Unknown` — `docs/typed-mode-spec.md` §3: a \
2906             lambda-bound local takes the lambda's own `fn(T…): R` type: \
2907             {diags:?}"
2908        );
2909    }
2910
2911    #[test]
2912    fn native_lambda_temp_shadowing_an_enclosing_local_does_not_poison_the_lambda_result() {
2913        // A regression this fix's own `self.locals` shadow had to grow to
2914        // cover: `g`'s `let a = "str";` reuses the enclosing `a`'s bare
2915        // name. Before extending the shadow past just params (issue #1910
2916        // review), the lambda's *first* `TempDecl` write of "a" `unify`d
2917        // with the enclosing `a: int`'s already-accumulated type —
2918        // `unify(int, string) == Conflicted` — and that `Conflicted` value,
2919        // now read back as this lambda's own tail type, made `map`'s whole
2920        // result (and so `scaled`'s return) `Conflicted` under strict.
2921        let diags = native_strict_diags(
2922            "fn scaled() {\n  let a = 1;\n  let items = [1, 2, 3];\n  let scaled = map(items, |x| {\n    let a = \"str\";\n    a\n  });\n  return len(scaled);\n}\n",
2923        );
2924        // Issue #1770: the lambda's own param `x` is never referenced
2925        // anywhere in its body (`{ let a = "str"; a }` only ever reads its
2926        // own fresh `a`), so it now correctly escapes as `E065` in its own
2927        // right — a genuinely new, unrelated finding. What this test still
2928        // pins is that `a` itself stays clean: `classify(String)` is
2929        // `Escape::Clean`, so the lambda's own shadowing `let a = "str";`
2930        // contributes no diagnostic of its own, and — the actual
2931        // regression this test guards — no `E066` appears anywhere (the
2932        // corruption this fixture was written to catch).
2933        assert_eq!(
2934            diags.len(),
2935            1,
2936            "only the lambda's own unused param `x` should escape — the \
2937             lambda's own `let a = \"str\";` is a fresh binding, wholly \
2938             unrelated to the enclosing `let a = 1;` of the same name, and \
2939             must not corrupt the lambda's own inferred `string` return \
2940             into `Conflicted`: {diags:?}"
2941        );
2942        assert_eq!(diags[0].code, DiagnosticCode::E065);
2943        assert!(
2944            diags[0].message.contains("lambda parameter `x`"),
2945            "{diags:?}"
2946        );
2947    }
2948
2949    #[test]
2950    fn native_lambda_param_does_not_inherit_an_enclosing_annotated_local_of_the_same_name() {
2951        // Regression (review follow-up on #1910): the `self.locals` shadow
2952        // `infer_lambda` grew is not the only bare-name-keyed map read
2953        // during the body walk — `self.annotated` is a second one, read
2954        // through `own_annotation`'s bare-single-segment fallback (used by
2955        // `or_own_annotation` for every intrinsic argument, and by
2956        // `annotated_callee_ty` for a direct-call callee). The enclosing
2957        // `let x: string = "s";` ascribes `x` in `self.annotated`; without
2958        // shadowing that map too, the lambda's own unannotated param `x`
2959        // (bound to an `int` from `items`) read the enclosing ascription
2960        // back through `own_annotation` and disagreed with the annotated
2961        // return type — `E063 annotated type Array<Option<int>> disagrees
2962        // with the type inferred from usage (Array<Option<string>>)`.
2963        let diags = native_strict_diags(
2964            "fn f(): Array<Option<int>> {\n  let x: string = \"s\";\n  let items = [1, 2, 3];\n  return map(items, |x| some(x));\n}\n",
2965        );
2966        // Issue #1770: the lambda's own param `x` is now visible to strict
2967        // inference in its own right — `some(x)` places no constraint on
2968        // `x`'s own type (mono-HM narrowing from a verb's own call site is
2969        // not modeled, `infer_lambda`'s own doc), so it correctly escapes
2970        // as `E065` on its own. What this test still pins is the *absence*
2971        // of the regression it was written for: no `E063` disagreement
2972        // between the annotated return type and a wrongly-inherited
2973        // `string` (the enclosing, unrelated `let x: string`'s type).
2974        assert_eq!(
2975            diags.len(),
2976            1,
2977            "only the lambda's own unconstrained param `x` should escape — \
2978             it must not inherit the enclosing `let x: string`'s annotated \
2979             type merely because they share a bare name: {diags:?}"
2980        );
2981        assert_eq!(diags[0].code, DiagnosticCode::E065);
2982        assert!(
2983            diags[0].message.contains("lambda parameter `x`"),
2984            "{diags:?}"
2985        );
2986    }
2987
2988    #[test]
2989    fn native_fold_accumulator_is_not_poisoned_by_an_unrelated_dotted_field_read() {
2990        // Regression (second review follow-up on #1910, issue #1924's gap):
2991        // `InferPass::infer_path` mistypes a captured struct's dotted field
2992        // read (`p.x`) as the struct itself, for lack of a static
2993        // field-type table (#1924). Before this guard, that wrong `Struct`
2994        // value reached `fold`'s own accumulator through an ordinary
2995        // `unify`/`observe` — `p.x + a` joins `Struct(Point)` with `a`
2996        // (`Unknown`, the identity), so `a`'s own narrowed type became
2997        // `Struct(Point)` too, and `fold`'s arm trusted it as the
2998        // accumulator's real type (never `Unknown`, so no seed fallback).
2999        // `g`'s own `: int` return annotation then disagreed with it —
3000        // `E063 annotated type int disagrees with the type inferred from
3001        // usage (Point)` — regressing code this exact shape compiled clean
3002        // under `main` before #1910, with no source-level workaround.
3003        let diags = native_strict_diags(
3004            "struct Point {\n  x: int,\n  y: int\n}\n\nfn g(): int {\n  let p = Point { x: 3, y: 4 };\n  let items = [1, 2];\n  return fold(items, 0, |a, b| p.x + a + b);\n}\n",
3005        );
3006        // Issue #1770: `a`/`b` now get their own per-lambda escape-check
3007        // frame. The dotted-field-read taint guard above (#1924's own
3008        // follow-up fix) makes `infer_lambda` fall back to an honest
3009        // `Unknown` for this callback's own signature rather than trust the
3010        // mistyped `Struct(Point)` value — so `a`/`b` correctly escape as
3011        // `E065` in their own right. What this test still pins is the
3012        // *absence* of the regression it was written for: no `E063`
3013        // disagreement between `g`'s `: int` return annotation and a
3014        // wrongly-poisoned `Point`.
3015        assert_eq!(
3016            diags.len(),
3017            2,
3018            "only the lambda's own two params should escape — `p.x`'s own \
3019             mistyped read must not poison `g`'s own `: int` return \
3020             annotation, which has nothing to do with `p`'s own struct \
3021             type: {diags:?}"
3022        );
3023        assert!(
3024            diags.iter().all(|d| d.code == DiagnosticCode::E065
3025                && (d.message.contains("lambda parameter `a`")
3026                    || d.message.contains("lambda parameter `b`"))),
3027            "{diags:?}"
3028        );
3029    }
3030
3031    #[test]
3032    fn native_verb_callback_param_still_escapes_when_the_body_places_no_constraint_on_it() {
3033        // The boundary this fix does NOT cross: `infer_lambda`'s own doc
3034        // ("mono-HM narrowing of a lambda's own params from its concrete
3035        // call sites is not modeled in this slice") — `scaled`'s callback
3036        // multiplies `x` by a captured, itself-unconstrained `factor`, so
3037        // neither ever resolves. This is `tests/tier1-native/lambda-verbs/
3038        // story.brink`'s `scaled` reduced to its essential shape, still an
3039        // expected (not #1910-fixed) baseline row.
3040        let diags = native_strict_diags(
3041            "fn scaled(factor) {\n  let items = [1, 2, 3];\n  return map(items, |x| x * factor);\n}\n",
3042        );
3043        assert!(
3044            diags.iter().any(|d| d.code == DiagnosticCode::E065),
3045            "`factor` is never pinned by any use anywhere in `scaled`'s own \
3046             body (call-site-driven inference is forbidden by \
3047             `docs/typed-mode-spec.md` §2), so both `factor` and the return \
3048             type must still escape: {diags:?}"
3049        );
3050    }
3051
3052    // ── issue #1941: a lambda's value-position read of an annotated ──
3053    // param still typed Unknown — the structurally parallel gap #1938 left
3054    // for `infer_return`'s fn-return position. `infer_lambda`'s tail
3055    // (`LambdaBody::Block`) and sole expression (`LambdaBody::Expr`) are
3056    // both a lambda's own value position, exactly like a `return`, and now
3057    // run through the same `or_own_annotation` read-site fallback.
3058
3059    #[test]
3060    fn native_lambda_tail_reading_a_content_param_takes_its_annotated_type() {
3061        // Issue #1941's own reduction, both halves: the lambda-return-
3062        // annotated twin was already clean (`infer_lambda`'s own
3063        // `l.return_type` overlay — the same firewall shape the fn case had
3064        // before #1938), the bare one let the lambda's own `Unknown` return
3065        // type escape through the enclosing temp `g`.
3066        let bare = native_strict_diags("fn f() {\n  let g = |t: content| {\n    t\n  };\n}\n");
3067        assert!(
3068            bare.is_empty(),
3069            "`t: content`'s tail read supplies the lambda's own return type: {bare:?}"
3070        );
3071        let annotated =
3072            native_strict_diags("fn f() {\n  let g = |t: content|: content {\n    t\n  };\n}\n");
3073        assert!(
3074            annotated.is_empty(),
3075            "the lambda-return-annotated twin stays clean: {annotated:?}"
3076        );
3077    }
3078
3079    #[test]
3080    fn native_lambda_tail_reading_an_annotated_param_is_not_content_specific() {
3081        // All four leaf spellings, mirroring #1938's own
3082        // `native_returning_an_annotated_param_is_not_content_specific` — a
3083        // fix that only special-cased `Ty::Content` would fail here.
3084        for ty in ["int", "float", "bool", "string"] {
3085            let src = format!("fn f() {{\n  let g = |t: {ty}| {{\n    t\n  }};\n}}\n");
3086            let diags = native_strict_diags(&src);
3087            assert!(
3088                diags.is_empty(),
3089                "`t: {ty}`'s tail read supplies the lambda's own return type: {diags:?}"
3090            );
3091        }
3092    }
3093
3094    #[test]
3095    fn native_lambda_expr_body_reading_an_annotated_param_exports_its_declared_type() {
3096        // The expression-bodied twin (`|t: content| t`, no braces) — the
3097        // *other* value-position read site #1941 fixed
3098        // (`LambdaBody::Expr`), a structurally distinct code path from the
3099        // block-tail arm above (`infer_lambda`'s own `match` on `l.body`).
3100        let diags = native_strict_diags("fn f() {\n  let g = |t: content| t;\n}\n");
3101        assert!(
3102            diags.is_empty(),
3103            "an expression-bodied lambda's sole expression is its value \
3104             position, exactly like a block's tail: {diags:?}"
3105        );
3106    }
3107
3108    #[test]
3109    fn native_lambda_param_annotation_seed_does_not_leak_into_a_rebound_temp_of_the_same_name() {
3110        // #1954 review finding (BLOCKING): `check_declared_assign_target`'s
3111        // own `SymbolKind::Temp` arm reads the same bare-name-keyed
3112        // `self.annotated` map the #1941 seed populates — it is a mismatch
3113        // *reporter*, not a pure read site like `own_annotation`'s other
3114        // consumers, and it cannot distinguish "the param's own annotation"
3115        // from "a fresh same-named local's own (absent) annotation". Without
3116        // excluding a body-rebound name from the seed, `t`'s param
3117        // annotation (`int`) leaked into the *lambda-local* `t` this body
3118        // re-declares, so assigning it a `string` falsely reported
3119        // `` `t` has type `string` but its declared type is `int` ``
3120        // (E063) even though the local `t` was never declared `: int` at
3121        // all. Verified empirically before this fix: reverting the
3122        // `body_bound_names` exclusion in `infer_lambda` reproduces this
3123        // exact diagnostic on this exact snippet.
3124        let diags = native_strict_diags(
3125            "fn f() {\n  let g = |t: int| {\n    let t = \"a\";\n    t = \"b\";\n    t\n  };\n}\n",
3126        );
3127        assert!(
3128            diags.is_empty(),
3129            "the lambda body's own `t` re-declaration shadows the param \
3130             entirely; it has no `int` annotation of its own to conflict \
3131             with a `string` assignment: {diags:?}"
3132        );
3133    }
3134
3135    #[test]
3136    fn native_lambda_param_annotation_seed_reaches_every_own_annotation_read_site_in_the_body() {
3137        // #1954 review finding: the #1941 seed's blast radius is wider than
3138        // the PR's own description states — it is read by
3139        // `own_annotation`'s bare-name fallback at *every*
3140        // `or_own_annotation`/`annotated_callee_ty` consumer reachable
3141        // during the body walk, not only the tail/expr value position. This
3142        // mirrors a `fn`/`flow`'s own `new_pass`-time seed, which already
3143        // covers its whole body, not only its `return`s — see
3144        // `docs/typed-mode-spec.md` §2's #1941 paragraph for the recorded
3145        // scope.
3146        //
3147        // `some(t)`: `t`'s annotated `int` reaches the intrinsic-argument
3148        // overlay (`infer_intrinsic_call`'s `or_own_annotation` pass over
3149        // each argument), which is what lets the tail's `Ty::Option(Int)`
3150        // resolve at all instead of escaping as `Unknown`.
3151        let via_intrinsic_arg =
3152            native_strict_diags("fn f() {\n  let g = |t: int| {\n    some(t)\n  };\n}\n");
3153        assert!(
3154            via_intrinsic_arg.is_empty(),
3155            "the seed reaches `some`'s argument-position read of `t`, not \
3156             just the lambda's own tail: {via_intrinsic_arg:?}"
3157        );
3158
3159        // `cb(1)`: `cb`'s annotated `fn(int): int` reaches
3160        // `annotated_callee_ty`'s direct-call-callee read, which is what
3161        // lets `cb` be called as a function value here rather than
3162        // escaping strict inference.
3163        let via_callee_ty =
3164            native_strict_diags("fn f() {\n  let g = |cb: fn(int): int| {\n    cb(1)\n  };\n}\n");
3165        assert!(
3166            via_callee_ty.is_empty(),
3167            "the seed reaches `annotated_callee_ty`'s direct-call read of \
3168             `cb`, not just the lambda's own tail: {via_callee_ty:?}"
3169        );
3170    }
3171
3172    // ── issue #1994: a lambda's own written annotation governs, with an ──
3173    // eager E174 on disagreement — exercised through `native_strict_diags`,
3174    // the same end-to-end harness the #1941/#1954 tests immediately above
3175    // use, not just at the `infer_lambda` unit level (review finding on
3176    // #1994: the hand-built HIR unit tests in `infer::body::tests` never
3177    // reach `strict::check_lambda_annotation_mismatches` at all).
3178
3179    #[test]
3180    fn native_lambda_return_annotation_disagreement_is_e174() {
3181        let diags =
3182            native_strict_diags("fn f() {\n  let g = |k: int|: int {\n    \"wrong\"\n  };\n}\n");
3183        assert_eq!(diags.len(), 1, "{diags:?}");
3184        assert_eq!(diags[0].code, DiagnosticCode::E174);
3185        assert!(
3186            diags[0]
3187                .message
3188                .contains("lambda return type is annotated `int` but its body infers `string`"),
3189            "{:?}",
3190            diags[0].message
3191        );
3192    }
3193
3194    #[test]
3195    fn native_lambda_param_annotation_disagreement_is_e174() {
3196        // The param-arm twin of the return test above: `k`'s only body
3197        // evidence (`k == true`, an expression-bodied tail so `g` itself
3198        // resolves to a concrete `Ty::Fn` rather than separately escaping)
3199        // pins it to `bool`, disagreeing with its own written `k: int`.
3200        // `int` vs `bool` is irreconcilable in either direction, so this
3201        // must still fire even with the widening-only guard in place.
3202        let diags = native_strict_diags("fn f() {\n  let g = |k: int| k == true;\n}\n");
3203        assert_eq!(diags.len(), 1, "{diags:?}");
3204        assert_eq!(diags[0].code, DiagnosticCode::E174);
3205        assert!(
3206            diags[0]
3207                .message
3208                .contains("lambda parameter `k` is annotated `int` but its body infers `bool`"),
3209            "{:?}",
3210            diags[0].message
3211        );
3212    }
3213
3214    #[test]
3215    fn native_lambda_param_widening_use_is_not_a_mismatch() {
3216        // Review finding (BLOCKING) on #1994: the param arm's original
3217        // `!assignable(&declared_ty, &inferred)` check compared in the
3218        // wrong direction for a parameter, turning legal int→float widening
3219        // into a hard, non-downgradable E174. An `int`-annotated param used
3220        // as a `float` in the body (ordinary numeric widening, exactly like
3221        // the structurally identical `fn f(x: int): float { return x +
3222        // 1.0; }` — which reports nothing under the pre-existing top-level
3223        // `fn`/`flow` posture) must stay clean.
3224        let diags = native_strict_diags("fn f() {\n  let g = |x: int| {\n    x + 1.0\n  };\n}\n");
3225        assert!(
3226            diags.is_empty(),
3227            "an int-annotated param used as a float is legal widening, not \
3228             a mismatch: {diags:?}"
3229        );
3230    }
3231
3232    // ── issue #1551: return-escape check extended past `is_function` ──
3233    //
3234    // `docs/decision-log.md` 2026-07-22 implicit-end ruling item 3: "a flow
3235    // that declares a return type must produce a value... falling through
3236    // without a value is a checker error" — these prove the checker
3237    // diagnostic that ruling promised (deferred at the time, per
3238    // `hir::lower_native::container`'s "not built by this slice" comment)
3239    // now fires, for both a top-level flow (knot) and a nested flow
3240    // (stitch), and that it is a distinct code (`E150`) from Unknown-escape
3241    // (`E065`) — the annotation-fallback in `infer::body::infer_def_body`
3242    // backfills a no-return body's inferred type from the declared
3243    // annotation, so E065's `Ty`-based classification structurally cannot
3244    // see a missing return (it comes out `Clean`).
3245
3246    #[test]
3247    fn native_value_returning_knot_falling_through_is_e150() {
3248        // Mirrors `a_return_typed_flow_does_not_get_the_implicit_done` in
3249        // `brink-ir`'s own lowering tests — same fixture, now checked.
3250        let diags = native_strict_diags("flow quest(): int {\n  Onward.\n}\n");
3251        assert_eq!(diags.len(), 1, "{diags:?}");
3252        assert_eq!(diags[0].code, DiagnosticCode::E150);
3253        assert!(
3254            diags[0].message.contains("never returns a value"),
3255            "{:?}",
3256            diags[0].message
3257        );
3258    }
3259
3260    #[test]
3261    fn native_value_returning_nested_stitch_falling_through_is_e150() {
3262        // Mirrors `a_return_typed_stitch_does_not_get_the_implicit_done`.
3263        let diags =
3264            native_strict_diags("flow garden() {\n  flow gate(): int {\n    Creak.\n  }\n}\n");
3265        assert_eq!(diags.len(), 1, "{diags:?}");
3266        assert_eq!(diags[0].code, DiagnosticCode::E150);
3267    }
3268
3269    #[test]
3270    fn native_value_returning_knot_that_always_returns_is_clean() {
3271        // The flip side of the falling-through cases above: a value-typed
3272        // flow whose body actually returns a concrete, resolvable value
3273        // gets no E150 (has_value_return is true) and is checked as an
3274        // ordinary Unknown/Conflicted escape instead — clean here since
3275        // `int` is concrete.
3276        let diags = native_strict_diags("flow quest(): int ~{\n  return 5;\n}\n");
3277        assert!(diags.is_empty(), "{diags:?}");
3278    }
3279
3280    #[test]
3281    fn native_value_returning_nested_stitch_that_always_returns_is_clean() {
3282        let diags =
3283            native_strict_diags("flow garden() {\n  flow gate(): int ~{\n    return 5;\n  }\n}\n");
3284        assert!(diags.is_empty(), "{diags:?}");
3285    }
3286
3287    #[test]
3288    fn native_value_returning_knot_with_unresolvable_return_still_escapes_as_unknown() {
3289        // A value is returned (has_value_return = true), so this goes
3290        // through the ordinary escape check, not E150 — an unconstrained
3291        // param's value flowing straight out is a genuine Unknown-escape.
3292        let diags = native_strict_diags("flow quest(x): int ~{\n  return x;\n}\n");
3293        assert!(
3294            diags.iter().any(|d| d.code == DiagnosticCode::E065),
3295            "{diags:?}"
3296        );
3297    }
3298
3299    #[test]
3300    fn native_void_annotated_knot_falling_through_is_exempt_from_e150() {
3301        // `: void` reads as "no return type" for the fall-through check on
3302        // a flow, same as it does for a `fn` — never E150.
3303        let diags = native_strict_diags("flow quest(): void {\n  Onward.\n}\n");
3304        assert!(diags.is_empty(), "{diags:?}");
3305    }
3306
3307    #[test]
3308    fn native_plain_knot_and_stitch_with_no_return_type_stay_unchecked() {
3309        // Baseline: no declared return type at all (and not `is_function`)
3310        // — no return-value concept, exactly as before #1551.
3311        let diags = native_strict_diags("flow quest() {\n  Onward.\n}\n");
3312        assert!(diags.is_empty(), "{diags:?}");
3313        let nested_diags =
3314            native_strict_diags("flow garden() {\n  flow gate() {\n    Creak.\n  }\n}\n");
3315        assert!(nested_diags.is_empty(), "{nested_diags:?}");
3316    }
3317
3318    #[test]
3319    fn native_annotated_function_falling_through_is_e150_latent_bug_fix() {
3320        // A pre-existing gap in the `is_function` case itself (found while
3321        // fixing #1551): before this fix, `fn f(): int { … no return … }`
3322        // inferred `is_void = true` via the old blanket
3323        // `!has_value_return` short-circuit and skipped checking
3324        // entirely — silently accepting a declared `int` that the body
3325        // never produces. Now a declared, non-void return type on a
3326        // no-return function is E150 too, the same as a flow/stitch.
3327        let diags = native_strict_diags("fn noop(): int {\n  let x = 1;\n}\n");
3328        assert_eq!(diags.len(), 1, "{diags:?}");
3329        assert_eq!(diags[0].code, DiagnosticCode::E150);
3330    }
3331
3332    #[test]
3333    fn native_void_annotated_def_that_actually_returns_a_value_is_exempt() {
3334        // Regression guard (review finding on #1556): a `: void`-annotated
3335        // def whose body *does* carry a value-returning `return <expr>` —
3336        // a body/annotation mismatch, not covered by this checker — must
3337        // not fall into the Unknown-escape branch just because
3338        // `has_value_return` is true. `void` reads as "no return type" for
3339        // escape purposes on both a `fn` and a flow/stitch, so the two
3340        // must agree: neither emits anything here (in particular, no
3341        // spurious second `E065` alongside whatever caught the param).
3342        // The param is annotated (exempt from its own Unknown-escape) so
3343        // any diagnostic here can only be the spurious return-type check.
3344        let function_diags = native_strict_diags("fn f(x: int): void {\n  return x;\n}\n");
3345        assert!(
3346            function_diags.is_empty(),
3347            "a void-annotated fn's own return value must not escape-check: {function_diags:?}"
3348        );
3349        let flow_diags = native_strict_diags("flow gate(x: int): void ~{\n  return x;\n}\n");
3350        assert!(
3351            flow_diags.is_empty(),
3352            "the flow/stitch twin must agree with the fn case: {flow_diags:?}"
3353        );
3354    }
3355
3356    #[test]
3357    fn native_value_returning_knot_with_a_partial_return_path_is_undocumented_gap() {
3358        // Pins the currently-undecided partial-path behavior the E150
3359        // message reword (review finding on #1556) made explicit: E150 only
3360        // fires when the body carries *no* value-returning `return`
3361        // anywhere (`has_value_return == false`). A body that returns a
3362        // value on *some* paths but can also fall through another (the
3363        // `else`-less `if` here) has `has_value_return == true`, so it
3364        // takes the ordinary escape-check branch instead — no E150, no
3365        // fall-through diagnostic of any kind, even though the `else` path
3366        // still falls through without a value. This is a known, documented
3367        // gap (#1551 asked to "decide (and document)" this shape; the
3368        // decision is deferred), not a fixed contract — this test exists
3369        // so a future change to that decision has to touch it deliberately.
3370        let diags =
3371            native_strict_diags("flow quest(): int ~{\n  if true {\n    return 1;\n  }\n}\n");
3372        assert!(
3373            diags.is_empty(),
3374            "partial-path fall-through is not currently detected: {diags:?}"
3375        );
3376    }
3377
3378    #[test]
3379    fn handle_param_escapes_as_unknown_with_no_manifest_registered() {
3380        let (hir, index, res) = build("=== noop(x: Handle<AudioInstance>) ===\nHello.\n-> DONE\n");
3381        let inference =
3382            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
3383        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
3384        assert_eq!(diags.len(), 1, "{diags:?}");
3385        assert_eq!(diags[0].code, DiagnosticCode::E065);
3386    }
3387
3388    /// T1d-2b (issue #774, docs/t1d-spec.md §3 — the #767 acceptance
3389    /// criterion): "binding declared Handle<AudioInstance> rejects
3390    /// Handle<Timer> at compile time". `get_audio`/`get_timer` are leaf
3391    /// functions whose return type is annotated with a distinct handle
3392    /// kind each — their body-derived return type stays `Unknown` (an
3393    /// unregistered `EXTERNAL`'s result is untyped and unchecked, see
3394    /// [`external_binding_with_unregistered_name_is_unchecked`]), so the
3395    /// T1c annotation-firewall overlay supplies the concrete
3396    /// `Ty::Handle(K)`. That opaque producer is what these fixtures need:
3397    /// a handle is an opaque `{kind, id}` scalar (docs/t1d-spec.md §3), not
3398    /// an `int`, so the `~ return id` these bodies used to carry was a real
3399    /// type error that only passed because reading an annotated param as a
3400    /// value typed `Unknown` — the gap issue #1912 closed. `main`'s temps
3401    /// `a`/`b` pick
3402    /// those return types up purely through call-site inference (never an
3403    /// annotation of their own), then get compared — a genuine cross-kind
3404    /// handle mismatch detected *purely from body-usage inference*, exactly
3405    /// the gap PR #769 disclosed as deferred. Before T1d-2b threaded the
3406    /// manifest into `infer_project`/`solve_scc`, `Handle<K>` annotations
3407    /// never resolved during body inference at all (an empty kind set), so
3408    /// `get_audio`/`get_timer` would return `Ty::Unknown`, `unify` would
3409    /// never see two distinct `Ty::Handle` kinds meet, and this mismatch
3410    /// was silently unreachable — this test is the positive case proving
3411    /// it is now reachable end-to-end.
3412    #[test]
3413    fn cross_kind_handle_comparison_from_body_usage_is_conflicted_under_strict() {
3414        // `spawn_audio`/`spawn_timer` are genuinely-registered `EXTERNAL`
3415        // producers (issue #1942's Scope section proposes "a
3416        // natively-registered producer" as one construction path): each
3417        // declares a fixed `returns` naming its own `Handle`-based
3418        // `SemanticTypeDef`, so `get_audio`/`get_timer`'s body-derived
3419        // return resolves directly to the concrete `Ty::Handle(K)` —
3420        // replacing the earlier *unregistered* `opaque_handle` workaround
3421        // (PR #1938) whose result was untyped and relied on the
3422        // annotation-firewall overlay alone.
3423        let src = "EXTERNAL spawn_audio()\nEXTERNAL spawn_timer()\n\
3424=== function get_audio(): Handle<AudioInstance> ===\n~ return spawn_audio()\n\
3425=== function get_timer(): Handle<Timer> ===\n~ return spawn_timer()\n\
3426=== main ===\n~ temp a = get_audio()\n~ temp b = get_timer()\n{a == b:\n  ok\n}\n-> DONE\n";
3427        let (hir, index, res) = build(src);
3428        let manifest = brink_ir::HostManifest {
3429            markup: Vec::new(),
3430            types: vec![
3431                brink_ir::SemanticTypeDef {
3432                    name: "AudioInstance".to_string(),
3433                    base: brink_ir::BaseType::Handle,
3434                    constraint: None,
3435                    values: None,
3436                    widget: None,
3437                },
3438                brink_ir::SemanticTypeDef {
3439                    name: "Timer".to_string(),
3440                    base: brink_ir::BaseType::Handle,
3441                    constraint: None,
3442                    values: None,
3443                    widget: None,
3444                },
3445            ],
3446            externals: vec![
3447                brink_ir::ManifestExternal {
3448                    name: "spawn_audio".to_string(),
3449                    params: Vec::new(),
3450                    returns: brink_ir::TypeRef("AudioInstance".to_string()),
3451                    kind: brink_ir::ExternalKind::default(),
3452                    doc: None,
3453                    widgets: Vec::new(),
3454                    path: Vec::new(),
3455                },
3456                brink_ir::ManifestExternal {
3457                    name: "spawn_timer".to_string(),
3458                    params: Vec::new(),
3459                    returns: brink_ir::TypeRef("Timer".to_string()),
3460                    kind: brink_ir::ExternalKind::default(),
3461                    doc: None,
3462                    widgets: Vec::new(),
3463                    path: Vec::new(),
3464                },
3465            ],
3466        };
3467        let inference = crate::infer_project(
3468            &[(FileId(0), &hir)],
3469            &index,
3470            &res,
3471            Some(&manifest),
3472            &BTreeMap::new(),
3473        );
3474        let diags = check(
3475            &[(FileId(0), &hir)],
3476            &index,
3477            &inference,
3478            &res,
3479            Some(&manifest),
3480        );
3481        assert!(
3482            diags
3483                .iter()
3484                .any(|d| d.code == DiagnosticCode::E066 && d.message.contains("temp `a`")),
3485            "cross-kind handle comparison must Conflicted-escape temp `a`: {diags:?}"
3486        );
3487        assert!(
3488            diags
3489                .iter()
3490                .any(|d| d.code == DiagnosticCode::E066 && d.message.contains("temp `b`")),
3491            "cross-kind handle comparison must Conflicted-escape temp `b`: {diags:?}"
3492        );
3493        assert!(
3494            diags.iter().all(|d| d.code == DiagnosticCode::E066),
3495            "no other diagnostic code expected: {diags:?}"
3496        );
3497    }
3498
3499    /// Negative counterpart: two locals of the *same* declared handle kind
3500    /// compared against each other unify cleanly (`unify(Handle(k),
3501    /// Handle(k)) == Handle(k)`, the T1d-2 lattice ruling) — no escape.
3502    #[test]
3503    fn same_kind_handle_comparison_from_body_usage_is_clean_under_strict() {
3504        // `spawn_audio` is a genuinely-registered `EXTERNAL` producer
3505        // (issue #1942) — see the sibling test above for the full
3506        // rationale.
3507        let src = "EXTERNAL spawn_audio()\n\
3508=== function get_audio(): Handle<AudioInstance> ===\n~ return spawn_audio()\n\
3509=== function get_audio2(): Handle<AudioInstance> ===\n~ return spawn_audio()\n\
3510=== main ===\n~ temp a = get_audio()\n~ temp c = get_audio2()\n{a == c:\n  ok\n}\n-> DONE\n";
3511        let (hir, index, res) = build(src);
3512        let manifest = brink_ir::HostManifest {
3513            markup: Vec::new(),
3514            types: vec![brink_ir::SemanticTypeDef {
3515                name: "AudioInstance".to_string(),
3516                base: brink_ir::BaseType::Handle,
3517                constraint: None,
3518                values: None,
3519                widget: None,
3520            }],
3521            externals: vec![brink_ir::ManifestExternal {
3522                name: "spawn_audio".to_string(),
3523                params: Vec::new(),
3524                returns: brink_ir::TypeRef("AudioInstance".to_string()),
3525                kind: brink_ir::ExternalKind::default(),
3526                doc: None,
3527                widgets: Vec::new(),
3528                path: Vec::new(),
3529            }],
3530        };
3531        let inference = crate::infer_project(
3532            &[(FileId(0), &hir)],
3533            &index,
3534            &res,
3535            Some(&manifest),
3536            &BTreeMap::new(),
3537        );
3538        let diags = check(
3539            &[(FileId(0), &hir)],
3540            &index,
3541            &inference,
3542            &res,
3543            Some(&manifest),
3544        );
3545        assert!(
3546            diags.is_empty(),
3547            "same-kind comparison must not escape: {diags:?}"
3548        );
3549    }
3550
3551    /// Issue #994: a dotted field read on a `Struct`-typed temp (`t.x`) must
3552    /// not corrupt `t`'s own accumulated type with the field-read's usage
3553    /// context. Before the fix, `infer::body::InferPass::observe` joined
3554    /// `useInt`'s `int` param type into temp `t`'s own slot (the TM-4b
3555    /// resolution fallback maps the whole dotted path's range to `t`'s
3556    /// `DefinitionId` — no static field-type table exists yet, so `t.x` and
3557    /// bare `t` were indistinguishable to `observe`), producing
3558    /// `unify(Struct(Point), int) == Conflicted` and a spurious `E066` on
3559    /// `t` even though `t` itself — a `Point` — is never actually misused.
3560    #[test]
3561    fn temp_headed_dotted_field_read_does_not_corrupt_the_temp_s_own_type() {
3562        let src = "STRUCT Point = #{x: float}\n\
3563                   === function useInt(n: int): int ===\n~ return n\n\
3564                   === main ===\n~ temp t = Point#{x: 1.0}\n~ temp r = useInt(t.x)\n-> DONE\n";
3565        let (hir, index, res) = build(src);
3566        let inference =
3567            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
3568        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
3569        assert!(
3570            diags.iter().all(|d| d.code != DiagnosticCode::E066),
3571            "a dotted field read must never Conflicted-escape its head temp: {diags:?}"
3572        );
3573    }
3574
3575    /// Control for the #994 fix above: the segment-count guard in `observe`
3576    /// only exempts a *dotted* field read — a bare (single-segment) temp
3577    /// whose own uses genuinely disagree must still Conflicted-escape.
3578    #[test]
3579    fn bare_temp_with_genuinely_conflicting_uses_still_escapes_as_conflicted() {
3580        let src = "=== function useInt(n: int): int ===\n~ return n\n\
3581                   === main ===\n~ temp t = \"hello\"\n~ temp r = useInt(t)\n-> DONE\n";
3582        let (hir, index, res) = build(src);
3583        let inference =
3584            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
3585        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
3586        assert!(
3587            diags
3588                .iter()
3589                .any(|d| d.code == DiagnosticCode::E066 && d.message.contains("temp `t`")),
3590            "a bare temp with genuinely conflicting uses must still Conflicted-escape: {diags:?}"
3591        );
3592    }
3593
3594    /// Before T1d-2b (issue #774), this exact cross-kind fixture was
3595    /// silently unreachable: `infer_project`/`solve_scc` had no manifest
3596    /// seam, so `Handle<K>` return annotations never resolved during body
3597    /// inference and both `get_audio`/`get_timer` returned `Ty::Unknown`
3598    /// instead of their distinct handle kinds — `unify(Unknown, Unknown)`
3599    /// stays `Unknown`, never `Conflicted`, so no escape ever fired even
3600    /// with a registered manifest. Pinned here as the regression guard for
3601    /// the specific "manifest reaches inference, not just `signature()`"
3602    /// gap PR #769 disclosed.
3603    #[test]
3604    fn cross_kind_handle_mismatch_is_unreachable_without_manifest_reaching_inference() {
3605        // `spawn_audio`/`spawn_timer` are genuinely-registered `EXTERNAL`
3606        // producers (issue #1942) — but the point of *this* test is that
3607        // `infer_project` below is handed `None`, so it never sees this
3608        // manifest at all: a registered producer's declared `returns` is
3609        // exactly as unresolvable as an unregistered external's absent one
3610        // when the manifest never reaches `infer_project`'s own
3611        // `ProjectCtx`. That is the pre-#774 gap this regression guards.
3612        let src = "EXTERNAL spawn_audio()\nEXTERNAL spawn_timer()\n\
3613=== function get_audio(): Handle<AudioInstance> ===\n~ return spawn_audio()\n\
3614=== function get_timer(): Handle<Timer> ===\n~ return spawn_timer()\n\
3615=== main ===\n~ temp a = get_audio()\n~ temp b = get_timer()\n{a == b:\n  ok\n}\n-> DONE\n";
3616        let (hir, index, res) = build(src);
3617        let manifest = brink_ir::HostManifest {
3618            markup: Vec::new(),
3619            types: vec![
3620                brink_ir::SemanticTypeDef {
3621                    name: "AudioInstance".to_string(),
3622                    base: brink_ir::BaseType::Handle,
3623                    constraint: None,
3624                    values: None,
3625                    widget: None,
3626                },
3627                brink_ir::SemanticTypeDef {
3628                    name: "Timer".to_string(),
3629                    base: brink_ir::BaseType::Handle,
3630                    constraint: None,
3631                    values: None,
3632                    widget: None,
3633                },
3634            ],
3635            externals: vec![
3636                brink_ir::ManifestExternal {
3637                    name: "spawn_audio".to_string(),
3638                    params: Vec::new(),
3639                    returns: brink_ir::TypeRef("AudioInstance".to_string()),
3640                    kind: brink_ir::ExternalKind::default(),
3641                    doc: None,
3642                    widgets: Vec::new(),
3643                    path: Vec::new(),
3644                },
3645                brink_ir::ManifestExternal {
3646                    name: "spawn_timer".to_string(),
3647                    params: Vec::new(),
3648                    returns: brink_ir::TypeRef("Timer".to_string()),
3649                    kind: brink_ir::ExternalKind::default(),
3650                    doc: None,
3651                    widgets: Vec::new(),
3652                    path: Vec::new(),
3653                },
3654            ],
3655        };
3656        // Manifest reaches `check()`'s own annotation resolution (the
3657        // pre-existing T1d-2 exemption seam), but `infer_project` here gets
3658        // `None` — simulating the pre-#774 gap where the manifest stopped
3659        // at `signature()`/the annotation firewall and never reached body
3660        // inference. `get_audio`/`get_timer`'s return types both come back
3661        // `Ty::Unknown` (the annotation can't resolve without the manifest
3662        // reaching `infer_project`'s own `ProjectCtx`), and `a`/`b`
3663        // inherit `Unknown`, which `check_escapes` reports as `E065`
3664        // (Unknown-escape), never `E066` (Conflicted) — proving the two
3665        // codes are genuinely distinguishing "never resolved" from "a real
3666        // kind mismatch", not interchangeable.
3667        let inference =
3668            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
3669        let diags = check(
3670            &[(FileId(0), &hir)],
3671            &index,
3672            &inference,
3673            &res,
3674            Some(&manifest),
3675        );
3676        assert!(
3677            diags.iter().all(|d| d.code == DiagnosticCode::E065),
3678            "with no manifest reaching inference, temps escape as Unknown, not Conflicted: {diags:?}"
3679        );
3680        assert!(
3681            !diags.iter().any(|d| d.code == DiagnosticCode::E066),
3682            "a real cross-kind mismatch must never be reachable without T1d-2b's fix: {diags:?}"
3683        );
3684    }
3685
3686    // ─── `EXTERNAL` call-site argument checking (issue #786) ────────────
3687    //
3688    // docs/t1d-spec.md §3's own acceptance criterion: "under `types =
3689    // strict`, a binding declared to take `Handle<AudioInstance>` rejects a
3690    // `Handle<Timer>` argument at compile time". T1d-2b (#774) closed this
3691    // for two *locals* meeting through body-usage inference (comparison,
3692    // reassignment); this closes the literal reading of the sentence — the
3693    // binding itself, at its own call site — reusing the identical
3694    // `known_sigs`/`observe`/`Ty::Conflicted`/`E066` machinery, no parallel
3695    // checking surface (see `infer::collect_external_sigs`'s doc).
3696
3697    fn audio_and_timer_manifest(play_sound_param_kind: &str) -> brink_ir::HostManifest {
3698        brink_ir::HostManifest {
3699            markup: Vec::new(),
3700            types: vec![
3701                brink_ir::SemanticTypeDef {
3702                    name: "AudioInstance".to_string(),
3703                    base: brink_ir::BaseType::Handle,
3704                    constraint: None,
3705                    values: None,
3706                    widget: None,
3707                },
3708                brink_ir::SemanticTypeDef {
3709                    name: "Timer".to_string(),
3710                    base: brink_ir::BaseType::Handle,
3711                    constraint: None,
3712                    values: None,
3713                    widget: None,
3714                },
3715            ],
3716            externals: vec![
3717                brink_ir::ManifestExternal {
3718                    name: "play_sound".to_string(),
3719                    params: vec![brink_ir::ManifestParam {
3720                        name: "inst".to_string(),
3721                        ty: brink_ir::TypeRef(play_sound_param_kind.to_string()),
3722                    }],
3723                    returns: brink_ir::TypeRef::default(),
3724                    kind: brink_ir::ExternalKind::default(),
3725                    doc: None,
3726                    widgets: Vec::new(),
3727                    path: Vec::new(),
3728                },
3729                // Genuinely-registered `EXTERNAL` producers (issue #1942's
3730                // Scope section proposes "a natively-registered producer"
3731                // as one construction path), replacing the earlier
3732                // *unregistered* `opaque_handle` workaround (PR #1938) the
3733                // tests below used to manufacture a handle value.
3734                brink_ir::ManifestExternal {
3735                    name: "spawn_audio".to_string(),
3736                    params: Vec::new(),
3737                    returns: brink_ir::TypeRef("AudioInstance".to_string()),
3738                    kind: brink_ir::ExternalKind::default(),
3739                    doc: None,
3740                    widgets: Vec::new(),
3741                    path: Vec::new(),
3742                },
3743                brink_ir::ManifestExternal {
3744                    name: "spawn_timer".to_string(),
3745                    params: Vec::new(),
3746                    returns: brink_ir::TypeRef("Timer".to_string()),
3747                    kind: brink_ir::ExternalKind::default(),
3748                    doc: None,
3749                    widgets: Vec::new(),
3750                    path: Vec::new(),
3751                },
3752            ],
3753        }
3754    }
3755
3756    /// The #767/#786 acceptance criterion, literally: a binding
3757    /// (`EXTERNAL play_sound`) declared in the manifest to take
3758    /// `Handle<AudioInstance>` rejects a `Handle<Timer>`-kinded argument at
3759    /// compile time under `types = strict`.
3760    #[test]
3761    fn external_binding_rejects_cross_kind_handle_argument_under_strict() {
3762        let src = "EXTERNAL play_sound(inst)\nEXTERNAL spawn_timer()\n\
3763=== function get_timer(): Handle<Timer> ===\n~ return spawn_timer()\n\
3764=== main ===\n~ temp t = get_timer()\n~ play_sound(t)\n-> DONE\n";
3765        let (hir, index, res) = build(src);
3766        let manifest = audio_and_timer_manifest("AudioInstance");
3767        let inference = crate::infer_project(
3768            &[(FileId(0), &hir)],
3769            &index,
3770            &res,
3771            Some(&manifest),
3772            &BTreeMap::new(),
3773        );
3774        let diags = check(
3775            &[(FileId(0), &hir)],
3776            &index,
3777            &inference,
3778            &res,
3779            Some(&manifest),
3780        );
3781        assert!(
3782            diags
3783                .iter()
3784                .any(|d| d.code == DiagnosticCode::E066 && d.message.contains("temp `t`")),
3785            "a Timer-kinded argument to an AudioInstance-declared binding must \
3786             Conflicted-escape temp `t`: {diags:?}"
3787        );
3788        assert!(
3789            diags.iter().all(|d| d.code == DiagnosticCode::E066),
3790            "no other diagnostic code expected: {diags:?}"
3791        );
3792    }
3793
3794    /// Negative counterpart: an argument of the binding's *own* declared
3795    /// kind is clean — no escape, matching `unify(Handle(k), Handle(k)) ==
3796    /// Handle(k)`.
3797    #[test]
3798    fn external_binding_accepts_same_kind_handle_argument_under_strict() {
3799        let src = "EXTERNAL play_sound(inst)\nEXTERNAL spawn_audio()\n\
3800=== function get_audio(): Handle<AudioInstance> ===\n~ return spawn_audio()\n\
3801=== main ===\n~ temp t = get_audio()\n~ play_sound(t)\n-> DONE\n";
3802        let (hir, index, res) = build(src);
3803        let manifest = audio_and_timer_manifest("AudioInstance");
3804        let inference = crate::infer_project(
3805            &[(FileId(0), &hir)],
3806            &index,
3807            &res,
3808            Some(&manifest),
3809            &BTreeMap::new(),
3810        );
3811        let diags = check(
3812            &[(FileId(0), &hir)],
3813            &index,
3814            &inference,
3815            &res,
3816            Some(&manifest),
3817        );
3818        assert!(
3819            diags.is_empty(),
3820            "same-kind binding argument must not escape: {diags:?}"
3821        );
3822    }
3823
3824    /// Gradual mode is unaffected: `strict_diagnostics` never even calls
3825    /// `infer_project`/`check` when `types = gradual` (this module's own
3826    /// `check` is only ever reached under strict), so a cross-kind argument
3827    /// to the same binding produces no compile-time diagnostic at all under
3828    /// gradual — the existing runtime fault at the binding boundary (T1d
3829    /// spec §3's own "under gradual, kind mismatch is a runtime fault"
3830    /// posture) stays the only enforcement, byte-identical to before this
3831    /// issue.
3832    #[test]
3833    fn external_binding_cross_kind_argument_is_not_checked_under_gradual() {
3834        let src = "EXTERNAL play_sound(inst)\nEXTERNAL spawn_timer()\n\
3835=== function get_timer(): Handle<Timer> ===\n~ return spawn_timer()\n\
3836=== main ===\n~ temp t = get_timer()\n~ play_sound(t)\n-> DONE\n";
3837        let (hir, index, res) = build(src);
3838        let manifest = audio_and_timer_manifest("AudioInstance");
3839        let opts = crate::AnalysisOptions {
3840            host_manifest: Some(manifest),
3841            dialect: crate::Dialect::Brink,
3842            types: Some(TypePolicy::Gradual),
3843            ..Default::default()
3844        };
3845        let diags = crate::strict_diagnostics(
3846            &[(FileId(0), &hir)],
3847            &index,
3848            &res,
3849            &opts,
3850            false,
3851            None,
3852            &BTreeMap::new(),
3853        );
3854        assert!(
3855            diags.is_empty(),
3856            "gradual mode must never run the strict handle-kind check: {diags:?}"
3857        );
3858    }
3859
3860    /// An `EXTERNAL` with no matching registered manifest entry contributes
3861    /// no checkable signature — the argument's own inferred kind (`Timer`,
3862    /// from `get_timer`'s registered `spawn_timer` producer, not the
3863    /// annotated return) stays clean, same as today (this is the disclosed
3864    /// inline-doc-only gap `infer::collect_external_sigs`'s doc names, not a
3865    /// regression).
3866    #[test]
3867    fn external_binding_with_unregistered_name_is_unchecked() {
3868        let src = "EXTERNAL other_call(inst)\nEXTERNAL spawn_timer()\n\
3869=== function get_timer(): Handle<Timer> ===\n~ return spawn_timer()\n\
3870=== main ===\n~ temp t = get_timer()\n~ other_call(t)\n-> DONE\n";
3871        let (hir, index, res) = build(src);
3872        let manifest = audio_and_timer_manifest("AudioInstance");
3873        let inference = crate::infer_project(
3874            &[(FileId(0), &hir)],
3875            &index,
3876            &res,
3877            Some(&manifest),
3878            &BTreeMap::new(),
3879        );
3880        let diags = check(
3881            &[(FileId(0), &hir)],
3882            &index,
3883            &inference,
3884            &res,
3885            Some(&manifest),
3886        );
3887        assert!(
3888            diags.is_empty(),
3889            "an unregistered external's call sites stay unchecked: {diags:?}"
3890        );
3891    }
3892
3893    // ── `EXTERNAL` *declaration* escape checking (issue #1004) ───────────
3894    //
3895    // The checks above verify call *arguments* against a binding's declared
3896    // param types. #1004 adds the dual: the binding's own declared params are
3897    // escape-checked, so a manifest whose `ManifestParam.ty` fails to resolve
3898    // is reported rather than silently treated as an untyped call. Exercised
3899    // through the shared `strict_diagnostics` seam (where `check_external_escapes`
3900    // is wired), the exact path both the analysis and compile pipelines take.
3901
3902    fn get_thing_manifest(ty: &str) -> brink_ir::HostManifest {
3903        brink_ir::HostManifest {
3904            markup: Vec::new(),
3905            types: vec![brink_ir::SemanticTypeDef {
3906                name: "thing_id".to_string(),
3907                base: brink_ir::BaseType::Int,
3908                constraint: None,
3909                values: None,
3910                widget: None,
3911            }],
3912            externals: vec![brink_ir::ManifestExternal {
3913                name: "get_thing".to_string(),
3914                params: vec![brink_ir::ManifestParam {
3915                    name: "id".to_string(),
3916                    ty: brink_ir::TypeRef(ty.to_string()),
3917                }],
3918                returns: brink_ir::TypeRef("float".to_string()),
3919                kind: brink_ir::ExternalKind::default(),
3920                doc: None,
3921                widgets: Vec::new(),
3922                path: Vec::new(),
3923            }],
3924        }
3925    }
3926
3927    fn strict_opts(manifest: Option<brink_ir::HostManifest>) -> crate::AnalysisOptions {
3928        crate::AnalysisOptions {
3929            host_manifest: manifest,
3930            dialect: crate::Dialect::Brink,
3931            types: Some(TypePolicy::Strict),
3932            ..Default::default()
3933        }
3934    }
3935
3936    const EXT_SRC: &str = "EXTERNAL get_thing(id)\n=== start ===\n{get_thing(1)}\n-> DONE\n";
3937
3938    #[test]
3939    fn manifest_typed_external_param_is_clean_under_strict() {
3940        let (hir, index, res) = build(EXT_SRC);
3941        let diags = crate::strict_diagnostics(
3942            &[(FileId(0), &hir)],
3943            &index,
3944            &res,
3945            &strict_opts(Some(get_thing_manifest("thing_id"))),
3946            false,
3947            None,
3948            &BTreeMap::new(),
3949        );
3950        assert!(
3951            diags.is_empty(),
3952            "a manifest-typed external param must not escape: {diags:?}"
3953        );
3954    }
3955
3956    #[test]
3957    fn unresolvable_external_param_escapes_at_its_own_decl_span() {
3958        let (hir, index, res) = build(EXT_SRC);
3959        let diags = crate::strict_diagnostics(
3960            &[(FileId(0), &hir)],
3961            &index,
3962            &res,
3963            &strict_opts(Some(get_thing_manifest(""))),
3964            false,
3965            None,
3966            &BTreeMap::new(),
3967        );
3968        let escape = diags
3969            .iter()
3970            .find(|d| d.code == DiagnosticCode::E065)
3971            .expect("expected an E065 escape from the unresolvable external param");
3972        assert!(
3973            escape.message.contains("get_thing") && escape.message.contains("parameter `id`"),
3974            "escape must name the offending external param: {escape:?}"
3975        );
3976        // `EXTERNAL get_thing(id)` — the `get_thing` name spans bytes 9..18.
3977        assert_eq!(
3978            (
3979                u32::from(escape.range.start()),
3980                u32::from(escape.range.end())
3981            ),
3982            (9, 18),
3983            "escape anchors at the external's own declaration span: {escape:?}"
3984        );
3985    }
3986
3987    #[test]
3988    fn unregistered_external_declaration_stays_unchecked_under_strict() {
3989        let (hir, index, res) = build(EXT_SRC);
3990        let diags = crate::strict_diagnostics(
3991            &[(FileId(0), &hir)],
3992            &index,
3993            &res,
3994            &strict_opts(None),
3995            false,
3996            None,
3997            &BTreeMap::new(),
3998        );
3999        assert!(
4000            diags.is_empty(),
4001            "an unregistered external's params must stay unchecked: {diags:?}"
4002        );
4003    }
4004
4005    #[test]
4006    fn external_declaration_escapes_never_fire_under_gradual() {
4007        let (hir, index, res) = build(EXT_SRC);
4008        let mut opts = strict_opts(Some(get_thing_manifest("")));
4009        opts.types = Some(TypePolicy::Gradual);
4010        let diags = crate::strict_diagnostics(
4011            &[(FileId(0), &hir)],
4012            &index,
4013            &res,
4014            &opts,
4015            false,
4016            None,
4017            &BTreeMap::new(),
4018        );
4019        assert!(
4020            diags.is_empty(),
4021            "gradual mode never escape-checks external declarations: {diags:?}"
4022        );
4023    }
4024
4025    #[test]
4026    fn unconstrained_empty_array_temp_escapes_as_unknown() {
4027        // spec §5's own worked example.
4028        let (hir, index, res) = build("=== main ===\n~ temp x = #[]\n-> DONE\n");
4029        let inference =
4030            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
4031        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
4032        assert_eq!(diags.len(), 1, "{diags:?}");
4033        assert_eq!(diags[0].code, DiagnosticCode::E065);
4034    }
4035
4036    #[test]
4037    fn annotated_empty_array_temp_is_exempt() {
4038        // spec §5: "if unconstrained, that's an Unknown escape -> annotate
4039        // the binding" — following that advice must silence the error.
4040        let (hir, index, res) = build("=== main ===\n~ temp x: Array<int> = #[]\n-> DONE\n");
4041        let inference =
4042            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
4043        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
4044        assert!(diags.is_empty(), "ascription supplies the type: {diags:?}");
4045    }
4046
4047    #[test]
4048    fn unannotated_function_with_no_return_statement_infers_void() {
4049        // Issue #1028: a function whose body never carries a value-returning
4050        // `return <expr>` — this one has no `return` at all — infers as void
4051        // exactly like an explicit `: void` annotation would, rather than
4052        // escaping as Unknown. Before #1028 this asserted an `E065` escape;
4053        // that was the exact gap the issue closed (typed-mode-spec §3 already
4054        // treats "no-return function" as `void`'s job — the annotation just
4055        // shouldn't be *required* to say what the body already proves).
4056        let (hir, index, res) = build("=== function noop() ===\nHello.\n");
4057        let inference =
4058            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
4059        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
4060        assert!(diags.is_empty(), "{diags:?}");
4061    }
4062
4063    #[test]
4064    fn unannotated_function_with_unresolvable_return_value_still_escapes() {
4065        // Issue #1028's flip side: a function *does* have a value-returning
4066        // `return`, but the value's own type can't be pinned down (`x` is an
4067        // otherwise-unconstrained param, which escapes in its own right too).
4068        // The return type must still `E065`-escape — void inference reads
4069        // "never returns a value", never "returns a value inference gave up
4070        // on".
4071        let (hir, index, res) = build("=== function noop(x) ===\n~ return x\n");
4072        let inference =
4073            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
4074        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
4075        assert_eq!(diags.len(), 2, "{diags:?}");
4076        assert!(diags.iter().all(|d| d.code == DiagnosticCode::E065));
4077        assert!(
4078            diags.iter().any(|d| d.message.contains("return type")),
4079            "expected a return-type escape among {diags:?}"
4080        );
4081    }
4082
4083    // ─── Issue #1168: Option-returning functions no longer E065-escape ──
4084
4085    /// The issue's tightest repro, at the diagnostic level: `some(x)`
4086    /// where `x: int` is never evidenced anywhere else in the body used to
4087    /// infer `Option[Unknown]` and trip `E065` with no annotation escape
4088    /// hatch. Fixed at the inference layer (`infer::body::InferPass::
4089    /// or_own_annotation`) — `strict::check` needs no changes, this pins
4090    /// the diagnostic-level outcome.
4091    #[test]
4092    fn some_of_an_unevidenced_annotated_param_no_longer_escapes() {
4093        let (hir, index, res) = build("=== function f(x: int) ===\n~ return some(x)\n");
4094        let inference =
4095            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
4096        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
4097        assert!(diags.is_empty(), "{diags:?}");
4098    }
4099
4100    /// `docs/book/src/toolchain/dialect/iteration.md`'s `first_over` fence
4101    /// (unmarked from `ink,proposed` in this same PR): a `for` loop over
4102    /// an annotated `Array<int>` param, `return some(<loop var>)` on one
4103    /// path and `return none` on the other — both the return type and the
4104    /// loop-var temp used to escape as `Unknown`.
4105    #[test]
4106    fn first_over_style_option_return_no_longer_escapes() {
4107        let (hir, index, res) = build(
4108            "=== function first_over(tab: Array<int>, floor: int) ===\n\
4109             ~ {\n    for coins in tab {\n        if coins > floor {\n            return some(coins)\n        }\n    }\n}\n\
4110             ~ return none\n",
4111        );
4112        let inference =
4113            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
4114        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
4115        assert!(diags.is_empty(), "{diags:?}");
4116    }
4117
4118    #[test]
4119    fn void_annotated_function_return_is_exempt() {
4120        let (hir, index, res) = build("=== function noop(): void ===\n~ return\n");
4121        let inference =
4122            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
4123        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
4124        assert!(diags.is_empty(), "{diags:?}");
4125    }
4126
4127    #[test]
4128    fn non_function_knot_return_is_never_checked() {
4129        // An ordinary knot has no return-value concept at all — never flagged
4130        // regardless of whether the body ever exercises `~ return`.
4131        let (hir, index, res) = build("=== main ===\nHello.\n-> DONE\n");
4132        let inference =
4133            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
4134        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
4135        assert!(diags.is_empty(), "{diags:?}");
4136    }
4137
4138    // ── issue #1028: void-return inference for a void-external wrapper ──
4139
4140    fn notify_manifest() -> brink_ir::HostManifest {
4141        brink_ir::HostManifest {
4142            markup: Vec::new(),
4143            types: Vec::new(),
4144            externals: vec![brink_ir::ManifestExternal {
4145                name: "notify".to_string(),
4146                params: Vec::new(),
4147                returns: brink_ir::TypeRef("void".to_string()),
4148                kind: brink_ir::ExternalKind::default(),
4149                doc: None,
4150                widgets: Vec::new(),
4151                path: Vec::new(),
4152            }],
4153        }
4154    }
4155
4156    #[test]
4157    fn wrapper_around_void_external_with_no_explicit_return_infers_void_and_is_strict_clean() {
4158        // The issue's own motivating shape: a function whose body only calls
4159        // a void external and never returns explicitly.
4160        let (hir, index, res) =
4161            build("EXTERNAL notify()\n=== function wrap_notify() ===\n~ notify()\n");
4162        let diags = crate::strict_diagnostics(
4163            &[(FileId(0), &hir)],
4164            &index,
4165            &res,
4166            &strict_opts(Some(notify_manifest())),
4167            false,
4168            None,
4169            &BTreeMap::new(),
4170        );
4171        assert!(
4172            diags.is_empty(),
4173            "a void-external wrapper with no explicit return must infer void, not \
4174             Unknown-escape: {diags:?}"
4175        );
4176    }
4177
4178    #[test]
4179    fn wrapper_around_void_external_with_a_real_return_path_is_unaffected() {
4180        // Adding a genuine value-returning path alongside the void-external
4181        // call must suppress the void inference exactly as before #1028 —
4182        // the wrapper's own return type still resolves concretely (`int`)
4183        // and stays clean, not because it's void but because `5` is.
4184        let (hir, index, res) = build(
4185            "EXTERNAL notify()\n=== function wrap_and_report() ===\n~ notify()\n~ return 5\n",
4186        );
4187        let inference = crate::infer_project(
4188            &[(FileId(0), &hir)],
4189            &index,
4190            &res,
4191            Some(&notify_manifest()),
4192            &BTreeMap::new(),
4193        );
4194        let wrap_id =
4195            annotations::def_id_for(&index, FileId(0), SymbolKind::Knot, "wrap_and_report")
4196                .expect("wrap_and_report must resolve");
4197        assert_eq!(
4198            inference.signatures.get(&wrap_id).map(|s| &s.return_ty),
4199            Some(&Ty::Int),
4200            "a real return path must still infer its own concrete type, unaffected by the \
4201             sibling void-external call"
4202        );
4203        let diags = crate::strict_diagnostics(
4204            &[(FileId(0), &hir)],
4205            &index,
4206            &res,
4207            &strict_opts(Some(notify_manifest())),
4208            false,
4209            None,
4210            &BTreeMap::new(),
4211        );
4212        assert!(diags.is_empty(), "{diags:?}");
4213    }
4214
4215    // ── check(): Conflicted-escape ─────────────────────────────────
4216
4217    #[test]
4218    fn genuinely_disjoint_param_uses_escape_as_conflicted() {
4219        let (hir, index, res) = build(
4220            "=== conflict_case(hp) ===\n{hp > 5:\n  ok\n}\n{hp == \"no\":\n  no\n}\n-> DONE\n",
4221        );
4222        let inference =
4223            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
4224        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
4225        assert_eq!(diags.len(), 1, "{diags:?}");
4226        assert_eq!(diags[0].code, DiagnosticCode::E066);
4227    }
4228
4229    #[test]
4230    fn annotation_never_exempts_a_conflicted_slot() {
4231        // Annotating a genuinely conflicted param doesn't heal the body's
4232        // internal contradiction — Conflicted-escape still fires (E063 stays
4233        // silent for the same reason: `is_unresolved()` covers Conflicted).
4234        let (hir, index, res) = build(
4235            "=== conflict_case(hp: int) ===\n{hp > 5:\n  ok\n}\n{hp == \"no\":\n  no\n}\n-> DONE\n",
4236        );
4237        let inference =
4238            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
4239        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
4240        assert_eq!(diags.len(), 1, "{diags:?}");
4241        assert_eq!(diags[0].code, DiagnosticCode::E066);
4242    }
4243
4244    #[test]
4245    fn heterogeneous_array_literal_temp_escapes_as_conflicted() {
4246        // spec §5: `#[1, "a"]` is an error — the join lattice already
4247        // produces `Array(Conflicted)`; this module's recursive classify
4248        // catches it through the nesting.
4249        let (hir, index, res) = build("=== main ===\n~ temp x = #[1, \"a\"]\n-> DONE\n");
4250        let inference =
4251            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
4252        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
4253        assert_eq!(diags.len(), 1, "{diags:?}");
4254        assert_eq!(diags[0].code, DiagnosticCode::E066);
4255    }
4256
4257    // ── §4 coercion lattice survives strict (regression guards) ────
4258
4259    #[test]
4260    fn condition_position_int_truthiness_survives_strict() {
4261        // `{visited_knot: ...}`-style int truthiness in condition position
4262        // must never escape — the type resolves cleanly to a concrete `int`.
4263        let (hir, index, res) = build("=== main ===\nVAR gold = 5\n{gold:\n  rich\n}\n-> DONE\n");
4264        let inference =
4265            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
4266        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
4267        assert!(diags.is_empty(), "{diags:?}");
4268    }
4269
4270    #[test]
4271    fn int_to_float_join_survives_strict_with_no_escape() {
4272        let (hir, index, res) = build("=== spend(gold) ===\n{gold > 1.5:\n  ok\n}\n-> DONE\n");
4273        let inference =
4274            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
4275        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
4276        assert!(
4277            diags.is_empty(),
4278            "int->float directional join is clean: {diags:?}"
4279        );
4280    }
4281
4282    // ── E063 wiring ──────────────────────────────────────────────────
4283
4284    #[test]
4285    fn check_wires_in_e063_mismatches() {
4286        let (hir, index, res) = build("=== heal(hp: string) ===\n{hp > 1:\n  ok\n}\n-> DONE\n");
4287        let inference =
4288            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
4289        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
4290        assert!(
4291            diags.iter().any(|d| d.code == DiagnosticCode::E063),
4292            "{diags:?}"
4293        );
4294    }
4295
4296    // ── determinism ──────────────────────────────────────────────────
4297
4298    #[test]
4299    fn escape_diagnostics_are_order_independent() {
4300        let forward =
4301            "=== conflict_fwd(hp) ===\n{hp > 5:\n  ok\n}\n{hp == \"no\":\n  no\n}\n-> DONE\n";
4302        let reversed =
4303            "=== conflict_rev(hp) ===\n{hp == \"no\":\n  no\n}\n{hp > 5:\n  ok\n}\n-> DONE\n";
4304
4305        let (hir_f, index_f, res_f) = build(forward);
4306        let inference_f = crate::infer_project(
4307            &[(FileId(0), &hir_f)],
4308            &index_f,
4309            &res_f,
4310            None,
4311            &BTreeMap::new(),
4312        );
4313        let diags_f = check(&[(FileId(0), &hir_f)], &index_f, &inference_f, &res_f, None);
4314
4315        let (hir_r, index_r, res_r) = build(reversed);
4316        let inference_r = crate::infer_project(
4317            &[(FileId(0), &hir_r)],
4318            &index_r,
4319            &res_r,
4320            None,
4321            &BTreeMap::new(),
4322        );
4323        let diags_r = check(&[(FileId(0), &hir_r)], &index_r, &inference_r, &res_r, None);
4324
4325        assert_eq!(codes(&diags_f), vec![DiagnosticCode::E066]);
4326        assert_eq!(codes(&diags_r), vec![DiagnosticCode::E066]);
4327    }
4328
4329    #[test]
4330    fn clean_strict_project_compiles_with_no_strict_diagnostics() {
4331        let (hir, index, res) = build(
4332            "=== function heal(hp: int): int ===\n~ temp bonus: int = 5\n~ return hp + bonus\n",
4333        );
4334        let inference =
4335            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
4336        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
4337        assert!(diags.is_empty(), "{diags:?}");
4338    }
4339
4340    // ── effective_severity ──────────────────────────────────────────
4341
4342    #[test]
4343    fn effective_severity_e063_is_warning_under_gradual() {
4344        assert_eq!(
4345            effective_severity(
4346                DiagnosticCode::E063,
4347                TypePolicy::Gradual,
4348                &LintPolicy::default()
4349            ),
4350            Some(brink_ir::Severity::Warning)
4351        );
4352    }
4353
4354    #[test]
4355    fn effective_severity_e063_is_error_under_strict() {
4356        assert_eq!(
4357            effective_severity(
4358                DiagnosticCode::E063,
4359                TypePolicy::Strict,
4360                &LintPolicy::default()
4361            ),
4362            Some(brink_ir::Severity::Error)
4363        );
4364    }
4365
4366    #[test]
4367    fn effective_severity_other_codes_are_policy_independent() {
4368        // A code with no strict-conditional carve-out keeps its default
4369        // severity regardless of policy — only E063 is ever conditioned.
4370        for policy in [TypePolicy::Gradual, TypePolicy::Strict] {
4371            assert_eq!(
4372                effective_severity(DiagnosticCode::E065, policy, &LintPolicy::default()),
4373                Some(DiagnosticCode::E065.severity())
4374            );
4375            assert_eq!(
4376                effective_severity(DiagnosticCode::E022, policy, &LintPolicy::default()),
4377                Some(DiagnosticCode::E022.severity())
4378            );
4379        }
4380    }
4381
4382    // ── effective_severity: [lints] (issue #1160) ───────────────────
4383
4384    #[test]
4385    fn absent_lints_table_is_byte_identical_to_default_severity() {
4386        // Every non-E063 code, under both policies, with an empty
4387        // `LintPolicy`: must match `DiagnosticCode::severity()` exactly —
4388        // the "absent table = today's behavior" acceptance criterion.
4389        for policy in [TypePolicy::Gradual, TypePolicy::Strict] {
4390            for code in [
4391                DiagnosticCode::E014,
4392                DiagnosticCode::E022,
4393                DiagnosticCode::E025,
4394                DiagnosticCode::E037,
4395            ] {
4396                assert_eq!(
4397                    effective_severity(code, policy, &LintPolicy::default()),
4398                    Some(code.severity()),
4399                    "code {code:?} under {policy:?} must be unaffected by an empty LintPolicy"
4400                );
4401            }
4402        }
4403    }
4404
4405    #[test]
4406    fn lint_override_deny_relevels_a_warning_code_to_error() {
4407        // E014 defaults to Warning.
4408        assert_eq!(DiagnosticCode::E014.severity(), brink_ir::Severity::Warning);
4409        let lints = LintPolicy {
4410            overrides: BTreeMap::from([("E014".to_owned(), LintLevel::Deny)]),
4411            deny_warnings: false,
4412        };
4413        assert_eq!(
4414            effective_severity(DiagnosticCode::E014, TypePolicy::Gradual, &lints),
4415            Some(brink_ir::Severity::Error)
4416        );
4417    }
4418
4419    #[test]
4420    fn lint_override_allow_suppresses_a_warning_code() {
4421        // Was `..._keeps_a_warning_code_at_warning`, asserting that `allow`
4422        // left the severity alone — which is what `allow` did, and was the
4423        // bug (#3173). `allow` is the only level that turns a diagnostic
4424        // off; a test named for the broken behaviour is how it survived.
4425        let lints = LintPolicy {
4426            overrides: BTreeMap::from([("E014".to_owned(), LintLevel::Allow)]),
4427            deny_warnings: false,
4428        };
4429        assert_eq!(
4430            effective_severity(DiagnosticCode::E014, TypePolicy::Gradual, &lints),
4431            None
4432        );
4433    }
4434
4435    // ── effective_severity: [lints] info/hint tier (issue #1162) ────
4436
4437    #[test]
4438    fn lint_override_info_relevels_a_warning_code_to_info() {
4439        let lints = LintPolicy {
4440            overrides: BTreeMap::from([("E014".to_owned(), LintLevel::Info)]),
4441            deny_warnings: false,
4442        };
4443        assert_eq!(
4444            effective_severity(DiagnosticCode::E014, TypePolicy::Gradual, &lints),
4445            Some(brink_ir::Severity::Info)
4446        );
4447    }
4448
4449    #[test]
4450    fn lint_override_hint_relevels_a_warning_code_to_hint() {
4451        let lints = LintPolicy {
4452            overrides: BTreeMap::from([("E014".to_owned(), LintLevel::Hint)]),
4453            deny_warnings: false,
4454        };
4455        assert_eq!(
4456            effective_severity(DiagnosticCode::E014, TypePolicy::Gradual, &lints),
4457            Some(brink_ir::Severity::Hint)
4458        );
4459    }
4460
4461    #[test]
4462    fn deny_warnings_does_not_touch_an_info_or_hint_override() {
4463        // Like `Allow`, `Info`/`Hint` are deliberate downgrades and must stay
4464        // immune to `deny-warnings` — escalating them back up would defeat
4465        // the point of setting them.
4466        let lints_info = LintPolicy {
4467            overrides: BTreeMap::from([("E014".to_owned(), LintLevel::Info)]),
4468            deny_warnings: true,
4469        };
4470        assert_eq!(
4471            effective_severity(DiagnosticCode::E014, TypePolicy::Gradual, &lints_info),
4472            Some(brink_ir::Severity::Info)
4473        );
4474        let lints_hint = LintPolicy {
4475            overrides: BTreeMap::from([("E014".to_owned(), LintLevel::Hint)]),
4476            deny_warnings: true,
4477        };
4478        assert_eq!(
4479            effective_severity(DiagnosticCode::E014, TypePolicy::Gradual, &lints_hint),
4480            Some(brink_ir::Severity::Hint)
4481        );
4482    }
4483
4484    #[test]
4485    fn hard_error_code_is_never_downgraded_to_info_or_hint() {
4486        // Same hard-error exemption as `Allow`/`Deny` — a code that is Error
4487        // by default is never even looked up in `[lints]`.
4488        assert_eq!(DiagnosticCode::E025.severity(), brink_ir::Severity::Error);
4489        let lints = LintPolicy {
4490            overrides: BTreeMap::from([("E025".to_owned(), LintLevel::Hint)]),
4491            deny_warnings: false,
4492        };
4493        assert_eq!(
4494            effective_severity(DiagnosticCode::E025, TypePolicy::Gradual, &lints),
4495            Some(brink_ir::Severity::Error)
4496        );
4497    }
4498
4499    #[test]
4500    fn deny_warnings_promotes_unconfigured_warning_codes_to_error() {
4501        let lints = LintPolicy {
4502            overrides: BTreeMap::new(),
4503            deny_warnings: true,
4504        };
4505        assert_eq!(
4506            effective_severity(DiagnosticCode::E014, TypePolicy::Gradual, &lints),
4507            Some(brink_ir::Severity::Error)
4508        );
4509        assert_eq!(
4510            effective_severity(DiagnosticCode::E022, TypePolicy::Gradual, &lints),
4511            Some(brink_ir::Severity::Error)
4512        );
4513    }
4514
4515    #[test]
4516    fn deny_warnings_cannot_resurrect_an_allowed_code() {
4517        // `allow` was always described as immune to `deny-warnings`; now
4518        // that it suppresses, immunity means the code stays GONE rather
4519        // than staying at Warning. `-D warnings` must not promote a
4520        // diagnostic the project switched off.
4521        let lints = LintPolicy {
4522            overrides: BTreeMap::from([("E014".to_owned(), LintLevel::Allow)]),
4523            deny_warnings: true,
4524        };
4525        assert_eq!(
4526            effective_severity(DiagnosticCode::E014, TypePolicy::Gradual, &lints),
4527            None
4528        );
4529    }
4530
4531    #[test]
4532    fn hard_error_code_is_never_downgraded_by_lints_or_deny_warnings() {
4533        // E025 (unresolved reference) defaults to Error and is not in the
4534        // Warning set — [lints] must never be consulted for it at all,
4535        // regardless of what a (nonsensical) override or deny-warnings say.
4536        assert_eq!(DiagnosticCode::E025.severity(), brink_ir::Severity::Error);
4537        let lints = LintPolicy {
4538            overrides: BTreeMap::from([("E025".to_owned(), LintLevel::Allow)]),
4539            deny_warnings: false,
4540        };
4541        assert_eq!(
4542            effective_severity(DiagnosticCode::E025, TypePolicy::Gradual, &lints),
4543            Some(brink_ir::Severity::Error)
4544        );
4545    }
4546
4547    #[test]
4548    fn deny_override_wins_even_without_deny_warnings() {
4549        let lints = LintPolicy {
4550            overrides: BTreeMap::from([("E014".to_owned(), LintLevel::Deny)]),
4551            deny_warnings: false,
4552        };
4553        assert_eq!(
4554            effective_severity(DiagnosticCode::E014, TypePolicy::Gradual, &lints),
4555            Some(brink_ir::Severity::Error)
4556        );
4557    }
4558
4559    /// Regression pin for the #1674 refactor: an *explicit* `[lints] E014 =
4560    /// "warn"` on a `Warning`-base code must still be escalated by
4561    /// `deny-warnings`, exactly like an unconfigured code (the pre-#1674
4562    /// implementation grouped `Some(Warn) | None` under one `if
4563    /// deny_warnings {Error} else {Warning}` arm — the generalized version
4564    /// must reach the same answer via its `candidate == Warning &&
4565    /// deny_warnings` check).
4566    #[test]
4567    fn explicit_warn_override_is_still_escalated_by_deny_warnings() {
4568        let lints = LintPolicy {
4569            overrides: BTreeMap::from([("E014".to_owned(), LintLevel::Warn)]),
4570            deny_warnings: true,
4571        };
4572        assert_eq!(
4573            effective_severity(DiagnosticCode::E014, TypePolicy::Gradual, &lints),
4574            Some(brink_ir::Severity::Error)
4575        );
4576    }
4577
4578    // ── effective_severity: Info/Hint-base codes (issue #1674) ──────
4579    //
4580    // `E157` is the first code whose *default* severity is `Info` rather
4581    // than `Warning` — these pin the generalized `effective_severity`/
4582    // `validate_lint_code` behavior for that base, alongside the
4583    // `Warning`-base regression coverage above (proving the widened
4584    // resolution order reaches the byte-identical answer for every
4585    // pre-#1674 case).
4586
4587    #[test]
4588    fn info_base_code_defaults_to_info_with_no_lints() {
4589        assert_eq!(
4590            DiagnosticCode::E157.severity(),
4591            brink_ir::Severity::Info,
4592            "E157 is the off/info-by-default lint issue #1674 rules for"
4593        );
4594        assert_eq!(
4595            effective_severity(
4596                DiagnosticCode::E157,
4597                TypePolicy::Gradual,
4598                &LintPolicy::default()
4599            ),
4600            Some(brink_ir::Severity::Info)
4601        );
4602    }
4603
4604    #[test]
4605    fn info_base_code_is_immune_to_deny_warnings_when_unconfigured() {
4606        // The whole point of defaulting to `Info`: a project that never
4607        // touches `[lints]` for E157 must not have `deny-warnings` promote
4608        // it to `Error` behind the author's back.
4609        let lints = LintPolicy {
4610            overrides: BTreeMap::new(),
4611            deny_warnings: true,
4612        };
4613        assert_eq!(
4614            effective_severity(DiagnosticCode::E157, TypePolicy::Gradual, &lints),
4615            Some(brink_ir::Severity::Info)
4616        );
4617    }
4618
4619    #[test]
4620    fn info_base_code_can_be_raised_to_warn_via_lints() {
4621        let lints = LintPolicy {
4622            overrides: BTreeMap::from([("E157".to_owned(), LintLevel::Warn)]),
4623            deny_warnings: false,
4624        };
4625        assert_eq!(
4626            effective_severity(DiagnosticCode::E157, TypePolicy::Gradual, &lints),
4627            Some(brink_ir::Severity::Warning)
4628        );
4629    }
4630
4631    #[test]
4632    fn info_base_code_raised_to_warn_is_then_escalated_by_deny_warnings() {
4633        let lints = LintPolicy {
4634            overrides: BTreeMap::from([("E157".to_owned(), LintLevel::Warn)]),
4635            deny_warnings: true,
4636        };
4637        assert_eq!(
4638            effective_severity(DiagnosticCode::E157, TypePolicy::Gradual, &lints),
4639            Some(brink_ir::Severity::Error)
4640        );
4641    }
4642
4643    #[test]
4644    fn info_base_code_can_be_denied_straight_to_error() {
4645        let lints = LintPolicy {
4646            overrides: BTreeMap::from([("E157".to_owned(), LintLevel::Deny)]),
4647            deny_warnings: false,
4648        };
4649        assert_eq!(
4650            effective_severity(DiagnosticCode::E157, TypePolicy::Gradual, &lints),
4651            Some(brink_ir::Severity::Error)
4652        );
4653    }
4654
4655    #[test]
4656    fn info_base_code_can_be_downleveled_to_hint() {
4657        let lints = LintPolicy {
4658            overrides: BTreeMap::from([("E157".to_owned(), LintLevel::Hint)]),
4659            deny_warnings: true,
4660        };
4661        assert_eq!(
4662            effective_severity(DiagnosticCode::E157, TypePolicy::Gradual, &lints),
4663            Some(brink_ir::Severity::Hint),
4664            "an explicit Hint downgrade must stay immune to deny-warnings too"
4665        );
4666    }
4667
4668    #[test]
4669    fn allow_suppresses_an_info_base_code_too() {
4670        // Was `info_base_code_allow_override_is_a_no_op` — the name said
4671        // outright that `allow` did nothing, and asserted it. An advisory
4672        // code is exactly the kind an author switches off (E189, the ink
4673        // TODO note, is the same tier).
4674        let lints = LintPolicy {
4675            overrides: BTreeMap::from([("E157".to_owned(), LintLevel::Allow)]),
4676            deny_warnings: true,
4677        };
4678        assert_eq!(
4679            effective_severity(DiagnosticCode::E157, TypePolicy::Gradual, &lints),
4680            None,
4681            "an Info-base code set to allow is suppressed, not left at Info"
4682        );
4683    }
4684
4685    // ── effective_severity: compat-deny tier (issue #3373) ──────────
4686    //
4687    // `E194` is `Error`-default (matches inklecate's own rejection) yet
4688    // `[lints]`-overridable — the one exception to the hard-error exemption
4689    // above. These pin that `effective_severity` actually reaches the
4690    // `[lints]` lookup for it, mirroring the `Warning`/`Info`-base coverage
4691    // above rather than trusting `is_overridable`/`validate_lint_code`
4692    // alone to prove it end to end.
4693
4694    #[test]
4695    fn compat_deny_code_defaults_to_error_with_no_lints() {
4696        assert_eq!(
4697            DiagnosticCode::E194.severity(),
4698            brink_ir::Severity::Error,
4699            "the compat-deny tier keeps inklecate's own rejection as the default"
4700        );
4701        assert_eq!(
4702            effective_severity(
4703                DiagnosticCode::E194,
4704                TypePolicy::Gradual,
4705                &LintPolicy::default()
4706            ),
4707            Some(brink_ir::Severity::Error)
4708        );
4709    }
4710
4711    #[test]
4712    fn compat_deny_code_can_be_downleveled_to_warn() {
4713        let lints = LintPolicy {
4714            overrides: BTreeMap::from([("E194".to_owned(), LintLevel::Warn)]),
4715            deny_warnings: false,
4716        };
4717        assert_eq!(
4718            effective_severity(DiagnosticCode::E194, TypePolicy::Gradual, &lints),
4719            Some(brink_ir::Severity::Warning)
4720        );
4721    }
4722
4723    #[test]
4724    fn compat_deny_code_can_be_downleveled_all_the_way_to_allow() {
4725        // The ruling's own wording: "we should allow it to be turned off if
4726        // the user wants, it's annoying" — `allow` must suppress it, not
4727        // merely warn it down.
4728        let lints = LintPolicy {
4729            overrides: BTreeMap::from([("E194".to_owned(), LintLevel::Allow)]),
4730            deny_warnings: false,
4731        };
4732        assert_eq!(
4733            effective_severity(DiagnosticCode::E194, TypePolicy::Gradual, &lints),
4734            None,
4735            "allow must suppress a compat-deny code entirely"
4736        );
4737    }
4738
4739    #[test]
4740    fn compat_deny_code_downleveled_to_warn_is_still_escalated_by_deny_warnings() {
4741        let lints = LintPolicy {
4742            overrides: BTreeMap::from([("E194".to_owned(), LintLevel::Warn)]),
4743            deny_warnings: true,
4744        };
4745        assert_eq!(
4746            effective_severity(DiagnosticCode::E194, TypePolicy::Gradual, &lints),
4747            Some(brink_ir::Severity::Error)
4748        );
4749    }
4750
4751    #[test]
4752    fn compat_deny_code_is_immune_to_deny_warnings_when_unconfigured() {
4753        // Unconfigured, it is already `Error` — `deny-warnings` promoting
4754        // `Warning` bases must not somehow read as "also re-derive this
4755        // one", since it was never `Warning` to begin with.
4756        let lints = LintPolicy {
4757            overrides: BTreeMap::new(),
4758            deny_warnings: true,
4759        };
4760        assert_eq!(
4761            effective_severity(DiagnosticCode::E194, TypePolicy::Gradual, &lints),
4762            Some(brink_ir::Severity::Error)
4763        );
4764    }
4765
4766    // ── check(): void-assignment (E067) ────────────────────────────
4767
4768    #[test]
4769    fn void_assigned_to_temp_is_e067() {
4770        let (hir, index, res) = build(
4771            "=== function noop(): void ===\n~ return\n\
4772             === main ===\n~ temp x = noop()\n-> DONE\n",
4773        );
4774        let inference =
4775            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
4776        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
4777        assert!(
4778            diags.iter().any(|d| d.code == DiagnosticCode::E067),
4779            "{diags:?}"
4780        );
4781    }
4782
4783    #[test]
4784    fn void_assigned_to_var_is_e067() {
4785        let (hir, index, res) = build(
4786            "VAR gold = 0\n=== function noop(): void ===\n~ return\n\
4787             === main ===\n~ gold = noop()\n-> DONE\n",
4788        );
4789        let inference =
4790            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
4791        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
4792        assert!(
4793            diags.iter().any(|d| d.code == DiagnosticCode::E067),
4794            "{diags:?}"
4795        );
4796    }
4797
4798    #[test]
4799    fn void_call_in_statement_position_is_clean() {
4800        let (hir, index, res) = build(
4801            "=== function noop(): void ===\n~ return\n\
4802             === main ===\n~ noop()\n-> DONE\n",
4803        );
4804        let inference =
4805            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
4806        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
4807        assert!(
4808            !diags.iter().any(|d| d.code == DiagnosticCode::E067),
4809            "statement-position void call must never be flagged: {diags:?}"
4810        );
4811    }
4812
4813    #[test]
4814    fn non_void_call_assigned_is_clean_of_e067() {
4815        let (hir, index, res) = build(
4816            "=== function give(): int ===\n~ return 5\n\
4817             === main ===\n~ temp x: int = give()\n-> DONE\n",
4818        );
4819        let inference =
4820            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
4821        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
4822        assert!(
4823            !diags.iter().any(|d| d.code == DiagnosticCode::E067),
4824            "{diags:?}"
4825        );
4826    }
4827
4828    #[test]
4829    fn inferred_void_assigned_to_temp_is_e067() {
4830        // Issue #1054: `noop` carries no `): void ===` annotation at all —
4831        // its void-ness is purely inferred (#1046: no value-returning
4832        // `return` anywhere in the body). Before this fix `collect_void_defs`
4833        // only ever consulted `knot.return_type`, so this assignment was
4834        // silently accepted; it must now `E067` exactly like the
4835        // explicitly-annotated case does.
4836        let (hir, index, res) = build(
4837            "=== function noop() ===\nHello.\n\
4838             === main ===\n~ temp x = noop()\n-> DONE\n",
4839        );
4840        let inference =
4841            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
4842        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
4843        assert!(
4844            diags.iter().any(|d| d.code == DiagnosticCode::E067),
4845            "{diags:?}"
4846        );
4847    }
4848
4849    #[test]
4850    fn inferred_void_assigned_to_var_is_e067() {
4851        let (hir, index, res) = build(
4852            "VAR gold = 0\n=== function noop() ===\nHello.\n\
4853             === main ===\n~ gold = noop()\n-> DONE\n",
4854        );
4855        let inference =
4856            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
4857        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
4858        assert!(
4859            diags.iter().any(|d| d.code == DiagnosticCode::E067),
4860            "{diags:?}"
4861        );
4862    }
4863
4864    #[test]
4865    fn inferred_void_call_in_statement_position_is_clean() {
4866        // Same firewall as the explicitly-annotated case: a statement-
4867        // position call never assigns the (nonexistent) result anywhere, so
4868        // it must stay clean regardless of whether void-ness is annotated or
4869        // inferred.
4870        let (hir, index, res) = build(
4871            "=== function noop() ===\nHello.\n\
4872             === main ===\n~ noop()\n-> DONE\n",
4873        );
4874        let inference =
4875            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
4876        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
4877        assert!(
4878            !diags.iter().any(|d| d.code == DiagnosticCode::E067),
4879            "statement-position inferred-void call must never be flagged: {diags:?}"
4880        );
4881    }
4882
4883    #[test]
4884    fn function_with_real_return_path_is_not_inferred_void_and_stays_clean_of_e067() {
4885        // Flip side of #1046's own inference rule: an unannotated function
4886        // that *does* return a value is not void — assigning its result must
4887        // not `E067`.
4888        let (hir, index, res) = build(
4889            "=== function give() ===\n~ return 5\n\
4890             === main ===\n~ temp x = give()\n-> DONE\n",
4891        );
4892        let inference =
4893            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
4894        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
4895        assert!(
4896            !diags.iter().any(|d| d.code == DiagnosticCode::E067),
4897            "{diags:?}"
4898        );
4899    }
4900
4901    #[test]
4902    fn stitch_return_value_reached_by_fallthrough_is_not_inferred_void_and_stays_clean_of_e067() {
4903        // Regression for the reviewer-caught gap in `collect_void_defs`: the
4904        // value-returning `return` lives in a *stitch* (`compute`), reached
4905        // by falling straight through the knot's own (empty) body — not by
4906        // an explicit divert. A stitch under a function knot is a separate
4907        // `Def` (`infer::collect_defs`, qualified name `f.compute`,
4908        // `SymbolKind::Stitch`) with its own `BodyTypes`, so the knot's own
4909        // `BodyTypes.has_value_return` is `false` even though the function
4910        // as a whole always returns a value. Before the fix this silently
4911        // inferred `f` as void and flagged `E067` on the assignment below.
4912        let (hir, index, res) = build(
4913            "=== function f() ===\n= compute\n~ return 5\n\
4914             === main ===\n~ temp x: int = f()\nx={x}\n-> DONE\n",
4915        );
4916        let inference =
4917            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
4918        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
4919        assert!(
4920            !diags.iter().any(|d| d.code == DiagnosticCode::E067),
4921            "{diags:?}"
4922        );
4923    }
4924
4925    #[test]
4926    fn declared_non_void_return_falling_through_is_e150_not_e067() {
4927        // Issue #1054's own excluded shape (see `collect_void_defs`'s doc
4928        // comment): a *declared*, non-`void` return type whose body never
4929        // returns a value is the #1551 checker error (`E150`, "declares a
4930        // return type but its body never returns a value") — not an
4931        // inferred-void function. It must never also `E067`-flag its own
4932        // assignment: the function is broken, not void, and reporting E067
4933        // on top would be misleading (asking to remove an assignment that
4934        // isn't actually the bug).
4935        let (hir, index, res) = build(
4936            "=== function broken(): int ===\nHello.\n\
4937             === main ===\n~ temp x = broken()\n-> DONE\n",
4938        );
4939        let inference =
4940            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
4941        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
4942        assert!(
4943            diags.iter().any(|d| d.code == DiagnosticCode::E150),
4944            "{diags:?}"
4945        );
4946        assert!(
4947            !diags.iter().any(|d| d.code == DiagnosticCode::E067),
4948            "a declared-return-type fall-through must be E150, not also E067: {diags:?}"
4949        );
4950    }
4951
4952    #[test]
4953    fn stitch_return_value_reached_by_fallthrough_is_not_e150() {
4954        // Issue #1591: the exact false positive from the issue body — the
4955        // value-returning `return` lives in the stitch (`compute`), reached
4956        // by falling straight through the knot's own (empty) body, not by
4957        // an explicit divert. `check_def`'s E150 path previously only read
4958        // the knot's own `BodyTypes.has_value_return` (`false`, since the
4959        // knot's own body before the first stitch never returns), so it
4960        // fired E150 even though the function as a whole always returns a
4961        // value. Twin of `stitch_return_value_reached_by_fallthrough_is_not_
4962        // inferred_void_and_stays_clean_of_e067` above, but for the E150
4963        // consumer instead of E067 — both now read the same shared
4964        // has-value-return-over-stitches fact.
4965        let (hir, index, res) = build("=== function f(): int ===\n= compute\n~ return 5\n");
4966        let inference =
4967            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
4968        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
4969        assert!(
4970            !diags.iter().any(|d| d.code == DiagnosticCode::E150),
4971            "{diags:?}"
4972        );
4973    }
4974
4975    #[test]
4976    fn unannotated_function_return_value_reached_by_fallthrough_stitch_is_not_e065() {
4977        // Regression for a reviewer-caught escape-check false positive:
4978        // an unannotated `fn` whose only value-returning `return` lives in
4979        // a fall-through stitch must stay clean under strict, exactly like
4980        // the identically-shaped own-body spelling (`=== function g() ===
4981        // \n~ return 5\n`, clean on both this fix and main). Before the
4982        // fix, `check_def`'s E065/E066 escape branch read the *merged*
4983        // has-value-return fact (the def's own body plus its stitches, per
4984        // `has_value_return_over_stitches`) instead of the def's own body
4985        // alone — so it treated the stitch's `return 5` as proof the
4986        // *knot's own* inferred return type was resolved, when
4987        // `sig.return_ty` (the thing actually being escape-checked) is
4988        // still `Unknown`: inference never merges a stitch's return type
4989        // into its owning knot's signature, only the has-value-return
4990        // *fact* is merged, and only for the E150/E067 consumers. That
4991        // made this fall-through spelling a hard compile error while the
4992        // own-body spelling compiled clean.
4993        let (hir, index, res) = build("=== function f() ===\n= compute\n~ return 5\n");
4994        let inference =
4995            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
4996        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
4997        assert!(diags.is_empty(), "{diags:?}");
4998    }
4999
5000    #[test]
5001    fn void_assignment_never_checked_under_gradual() {
5002        // `check`'s void-assignment pass is unconditional — it's
5003        // `finish_analysis` that gates the whole `strict::check` call behind
5004        // `opts.types == TypePolicy::Strict`. Exercise that real gate (not
5005        // `check` directly) to prove a void assignment stays silent under
5006        // gradual, matching this module's "byte-identical forever" contract.
5007        let parsed = brink_syntax::parse(
5008            "=== function noop(): void ===\n~ return\n\
5009             === main ===\n~ temp x = noop()\n-> DONE\n",
5010        );
5011        let (hir, manifest, _diag) = brink_ir::hir::lower(FileId(0), &parsed.tree());
5012        let opts = crate::AnalysisOptions {
5013            dialect: crate::Dialect::Brink,
5014            // These tests TEST gradual behavior — explicit opt-out knob
5015            // (#1127: the brink dialect's implicit default is now strict).
5016            types: Some(TypePolicy::Gradual),
5017            ..crate::AnalysisOptions::default()
5018        };
5019        let result = crate::analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
5020        assert!(
5021            !result
5022                .diagnostics
5023                .iter()
5024                .any(|d| d.code == DiagnosticCode::E067),
5025            "gradual must never surface E067: {:?}",
5026            result.diagnostics
5027        );
5028    }
5029
5030    // ── T1c: calls through function values (docs/t1c-spec.md §4/§8) ────
5031
5032    /// The spec's worked example, end to end at the analysis layer: a
5033    /// well-formed creation + a well-typed call through the value is clean
5034    /// under strict.
5035    const HEAL: &str = "=== function heal(ref hp: int, amount: int): int ===\n~ hp = hp + amount\n~ return hp\n\
5036         VAR player_hp = 10\n";
5037
5038    #[test]
5039    fn well_typed_call_through_a_fn_value_is_clean_under_strict() {
5040        let (hir, index, res) = build(&format!(
5041            "{HEAL}=== main ===\n~ temp heal_player = #fn(heal, player_hp)\n\
5042             ~ temp result: int = heal_player(5)\n-> DONE\n"
5043        ));
5044        let inference =
5045            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
5046        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
5047        assert!(diags.is_empty(), "{diags:?}");
5048    }
5049
5050    /// Issue #1680 step 2 — the regression this whole step exists for.
5051    ///
5052    /// `apply`'s unannotated `cb` param is reassigned to `#fn(bump)` inside
5053    /// the body, so its *inferred* type carries `bump` as its effect row
5054    /// (issue #1680 step 3). A caller that passes `#fn(twice)` in that
5055    /// position is passing a perfectly well-typed `fn(int): int` — but the
5056    /// two rows differ, so a **structural** `unify(param, arg) == param`
5057    /// test sees the join widen and reports `ValueCallKind::ArgMismatch`,
5058    /// which `effective_severity` promotes to an `E063` **error** under
5059    /// `types = strict`. The message is self-refuting ("expected
5060    /// `fn(int): int`, found `fn(int): int`") because rows are not part of
5061    /// `Ty::display`.
5062    ///
5063    /// `infer::assignable` erases rows on both sides, which is what keeps
5064    /// this clean.
5065    #[test]
5066    fn differing_effect_rows_are_not_an_argument_mismatch() {
5067        let (hir, index, res) = build(
5068            "=== function bump(n: int): int ===\n~ return n + 1\n\
5069             === function twice(n: int): int ===\n~ return n * 2\n\
5070             === function apply(cb, x: int): int ===\n\
5071             ~ cb = #fn(bump)\n~ return cb(x)\n\
5072             === main ===\n~ temp a = #fn(apply)\n\
5073             ~ temp r: int = a(#fn(twice), 1)\n-> DONE\n",
5074        );
5075        let inference =
5076            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
5077        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
5078        assert!(
5079            !diags
5080                .iter()
5081                .any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument 1")),
5082            "a differing effect row is not a type mismatch: {diags:?}"
5083        );
5084    }
5085
5086    /// The second `ValueCallKind::ArgMismatch` site — `check_bind_value`,
5087    /// the `bind(f, args…)` form. Same fixture shape as
5088    /// [`differing_effect_rows_are_not_an_argument_mismatch`], routed
5089    /// through partial application instead of a direct value call, because
5090    /// the two sites carry independent copies of the assignability test.
5091    #[test]
5092    fn differing_effect_rows_are_not_a_bind_argument_mismatch() {
5093        let (hir, index, res) = build(
5094            "=== function bump(n: int): int ===\n~ return n + 1\n\
5095             === function twice(n: int): int ===\n~ return n * 2\n\
5096             === function apply(cb, x: int): int ===\n\
5097             ~ cb = #fn(bump)\n~ return cb(x)\n\
5098             === main ===\n~ temp a = #fn(apply)\n\
5099             ~ temp p = bind(a, #fn(twice))\n~ temp r: int = p(1)\n-> DONE\n",
5100        );
5101        let inference =
5102            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
5103        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
5104        assert!(
5105            !diags
5106                .iter()
5107                .any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument 1")),
5108            "a differing effect row is not a bind mismatch: {diags:?}"
5109        );
5110    }
5111
5112    #[test]
5113    fn int_to_float_coercion_applies_to_fn_value_call_arguments() {
5114        // `fn(float): float` called with an int literal — the one legal
5115        // directional coercion (spec §4) applies exactly as at direct calls.
5116        let (hir, index, res) = build(
5117            "=== function scale(factor: float): float ===\n~ return factor * 2.0\n\
5118             === main ===\n~ temp f = #fn(scale)\n~ temp r: float = f(2)\n-> DONE\n",
5119        );
5120        let inference =
5121            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
5122        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
5123        assert!(diags.is_empty(), "{diags:?}");
5124    }
5125
5126    #[test]
5127    fn fn_value_call_arity_mismatch_is_a_typed_mismatch_error() {
5128        let (hir, index, res) = build(&format!(
5129            "{HEAL}=== main ===\n~ temp f = #fn(heal, player_hp)\n\
5130             ~ temp r: int = f(5, 6)\n-> DONE\n"
5131        ));
5132        let inference =
5133            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
5134        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
5135        assert_eq!(diags.len(), 1, "{diags:?}");
5136        assert_eq!(diags[0].code, DiagnosticCode::E063);
5137        assert!(diags[0].message.contains("2 argument"), "{diags:?}");
5138    }
5139
5140    #[test]
5141    fn fn_value_call_argument_type_mismatch_is_a_typed_mismatch_error() {
5142        let (hir, index, res) = build(&format!(
5143            "{HEAL}=== main ===\n~ temp f = #fn(heal, player_hp)\n\
5144             ~ temp r: int = f(\"lots\")\n-> DONE\n"
5145        ));
5146        let inference =
5147            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
5148        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
5149        assert!(
5150            diags
5151                .iter()
5152                .any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument 1")),
5153            "{diags:?}"
5154        );
5155    }
5156
5157    #[test]
5158    fn float_to_int_narrowing_at_a_fn_value_call_is_an_error() {
5159        // The coercion is directional: int -> float only. `fn(int): int`
5160        // called with a float literal must be flagged.
5161        let (hir, index, res) = build(&format!(
5162            "{HEAL}=== main ===\n~ temp f = #fn(heal, player_hp)\n\
5163             ~ temp r: int = f(1.5)\n-> DONE\n"
5164        ));
5165        let inference =
5166            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
5167        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
5168        assert!(
5169            diags
5170                .iter()
5171                .any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument 1")),
5172            "{diags:?}"
5173        );
5174    }
5175
5176    #[test]
5177    fn unknown_callee_in_call_position_is_an_escape_error() {
5178        // A call through a value whose type never resolves — the TM-3
5179        // escape rule applied to call position (spec §4: "if the callee's
5180        // type is Unknown/Conflicted, that is an escape error").
5181        let (hir, index, res) = build("=== main(g) ===\n~ temp r = g(1)\n-> DONE\n");
5182        let inference =
5183            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
5184        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
5185        assert!(
5186            diags.iter().any(|d| d.code == DiagnosticCode::E065
5187                && d.message.contains("called as a function value")),
5188            "{diags:?}"
5189        );
5190    }
5191
5192    #[test]
5193    fn conflicted_callee_in_call_position_is_a_conflicted_escape_error() {
5194        let (hir, index, res) =
5195            build("=== main ===\n~ temp f = 1\n{f == \"x\":\n  no\n}\n~ temp r = f(5)\n-> DONE\n");
5196        let inference =
5197            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
5198        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
5199        assert!(
5200            diags.iter().any(|d| d.code == DiagnosticCode::E066
5201                && d.message.contains("called as a function value")),
5202            "{diags:?}"
5203        );
5204    }
5205
5206    #[test]
5207    fn calling_a_known_non_fn_value_is_a_typed_mismatch_error() {
5208        let (hir, index, res) = build("=== main ===\n~ temp n = 5\n~ temp r = n(1)\n-> DONE\n");
5209        let inference =
5210            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
5211        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
5212        assert!(
5213            diags
5214                .iter()
5215                .any(|d| d.code == DiagnosticCode::E063 && d.message.contains("not callable")),
5216            "{diags:?}"
5217        );
5218    }
5219
5220    #[test]
5221    fn annotated_fn_typed_param_is_callable_under_strict() {
5222        // The boundary-annotation form (spec §4: "fn-typed params can cross
5223        // host boundaries under strict"): `cb`'s only constraint is its
5224        // annotation, and the call through it checks against that row.
5225        let (hir, index, res) =
5226            build("=== function apply(cb: fn(int): int, x: int): int ===\n~ return cb(x)\n");
5227        let inference =
5228            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
5229        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
5230        assert!(diags.is_empty(), "{diags:?}");
5231    }
5232
5233    #[test]
5234    fn annotated_fn_typed_param_call_still_checks_argument_types() {
5235        let (hir, index, res) =
5236            build("=== function apply(cb: fn(int): int): int ===\n~ return cb(\"nope\")\n");
5237        let inference =
5238            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
5239        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
5240        assert!(
5241            diags
5242                .iter()
5243                .any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument 1")),
5244            "{diags:?}"
5245        );
5246    }
5247
5248    #[test]
5249    fn fn_value_call_checks_never_surface_under_gradual() {
5250        // The real production gate: `finish_analysis` only calls
5251        // `strict::check` under `types = strict` — gradual stays advisory
5252        // (the §3 runtime fault is its backstop).
5253        let parsed = brink_syntax::parse("=== main ===\n~ temp n = 5\n~ temp r = n(1)\n-> DONE\n");
5254        let (hir, manifest, _diag) = brink_ir::hir::lower(FileId(0), &parsed.tree());
5255        let opts = crate::AnalysisOptions {
5256            dialect: crate::Dialect::Brink,
5257            // These tests TEST gradual behavior — explicit opt-out knob
5258            // (#1127: the brink dialect's implicit default is now strict).
5259            types: Some(TypePolicy::Gradual),
5260            ..crate::AnalysisOptions::default()
5261        };
5262        let result = crate::analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
5263        assert!(
5264            !result.diagnostics.iter().any(|d| matches!(
5265                d.code,
5266                DiagnosticCode::E063 | DiagnosticCode::E065 | DiagnosticCode::E066
5267            )),
5268            "gradual must never surface value-call checks: {:?}",
5269            result.diagnostics
5270        );
5271    }
5272
5273    #[test]
5274    fn strict_fn_value_mismatch_fires_through_the_real_pipeline() {
5275        // analyze_with_options -> finish_analysis -> whole_project_
5276        // diagnostics -> strict::check — the wiring, not just the unit.
5277        let parsed = brink_syntax::parse(
5278            "=== function heal(ref hp: int, amount: int): int ===\n~ hp = hp + amount\n~ return hp\n\
5279             VAR player_hp = 10\n\
5280             === main ===\n~ temp f = #fn(heal, player_hp)\n~ temp r: int = f(\"x\")\n-> DONE\n",
5281        );
5282        let (hir, manifest, _diag) = brink_ir::hir::lower(FileId(0), &parsed.tree());
5283        let opts = crate::AnalysisOptions {
5284            dialect: crate::Dialect::Brink,
5285            types: Some(TypePolicy::Strict),
5286            ..crate::AnalysisOptions::default()
5287        };
5288        let result = crate::analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
5289        assert!(
5290            result
5291                .diagnostics
5292                .iter()
5293                .any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument 1")),
5294            "{:?}",
5295            result.diagnostics
5296        );
5297    }
5298
5299    // ── T1c follow-up (issue #712): declaration-derived Ty::Fn for global
5300    //    VARs (docs/t1c-spec.md §4) ───────────────────────────────────
5301
5302    /// Same worked example as `HEAL`, but the fn value itself is a *global*
5303    /// (`VAR heal_player = #fn(heal, player_hp)`), not a local temp — the
5304    /// exact shape #712 closes: a global's declaration-derived signature
5305    /// must carry `Ty::Fn` so a call *through the global directly* type-
5306    /// checks under strict instead of escaping as Unknown.
5307    const HEAL_GLOBAL: &str = "=== function heal(ref hp: int, amount: int): int ===\n\
5308         ~ hp = hp + amount\n~ return hp\n\
5309         VAR player_hp = 10\n\
5310         VAR heal_player = #fn(heal, player_hp)\n";
5311
5312    #[test]
5313    fn well_typed_call_through_a_global_fn_value_is_clean_under_strict() {
5314        let (hir, index, res) = build(&format!(
5315            "{HEAL_GLOBAL}=== main ===\n~ temp result: int = heal_player(5)\n-> DONE\n"
5316        ));
5317        let inference =
5318            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
5319        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
5320        assert!(diags.is_empty(), "{diags:?}");
5321    }
5322
5323    #[test]
5324    fn arity_mismatch_through_a_global_fn_value_is_a_typed_mismatch_error() {
5325        let (hir, index, res) = build(&format!(
5326            "{HEAL_GLOBAL}=== main ===\n~ temp r: int = heal_player(5, 6)\n-> DONE\n"
5327        ));
5328        let inference =
5329            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
5330        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
5331        assert_eq!(diags.len(), 1, "{diags:?}");
5332        assert_eq!(diags[0].code, DiagnosticCode::E063);
5333        assert!(diags[0].message.contains("2 argument"), "{diags:?}");
5334    }
5335
5336    #[test]
5337    fn argument_type_mismatch_through_a_global_fn_value_is_a_typed_mismatch_error() {
5338        let (hir, index, res) = build(&format!(
5339            "{HEAL_GLOBAL}=== main ===\n~ temp r: int = heal_player(\"lots\")\n-> DONE\n"
5340        ));
5341        let inference =
5342            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
5343        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
5344        assert!(
5345            diags
5346                .iter()
5347                .any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument 1")),
5348            "{diags:?}"
5349        );
5350    }
5351
5352    #[test]
5353    fn explicitly_annotated_global_fn_value_wins_over_an_unannotated_target() {
5354        // `identity` carries no param/return annotations at all, so the
5355        // `#fn(identity)` initializer alone would infer an all-`Unknown`
5356        // row — but `f`'s own `fn(int): int` annotation must win (the same
5357        // TM-2 firewall rule `value_type` already applies), so the
5358        // wrong-typed call below is still caught, not silently waved
5359        // through as an Unknown-escape.
5360        let (hir, index, res) = build(
5361            "=== function identity(x) ===\n~ return x\n\
5362             VAR f: fn(int): int = #fn(identity)\n\
5363             === main ===\n~ temp r: int = f(\"x\")\n-> DONE\n",
5364        );
5365        let inference =
5366            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
5367        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
5368        assert!(
5369            diags
5370                .iter()
5371                .any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument 1")),
5372            "{diags:?}"
5373        );
5374    }
5375
5376    #[test]
5377    fn cross_signature_reassignment_through_globals_is_a_conflicted_escape() {
5378        // Two globals with genuinely incompatible `fn(T…): R` shapes; a
5379        // temp bound from one and reassigned from the other joins to a
5380        // `Ty::Fn` row carrying `Conflicted` components (the pre-existing
5381        // #627 pointwise Fn×Fn unify — no new unify logic needed here),
5382        // which the ordinary temp Conflicted-escape check (`E066`) already
5383        // catches once the globals themselves carry real `Ty::Fn`s instead
5384        // of both escaping as `Unknown` (which would `unify` to `Unknown`,
5385        // not `Conflicted`, silently hiding the disagreement).
5386        let (hir, index, res) = build(
5387            "=== function heal(ref hp: int, amount: int): int ===\n\
5388             ~ hp = hp + amount\n~ return hp\n\
5389             === function greet(name: string): string ===\n~ return name\n\
5390             VAR player_hp = 10\n\
5391             VAR heal_fn = #fn(heal, player_hp)\n\
5392             VAR greet_fn = #fn(greet)\n\
5393             === main ===\n~ temp f = heal_fn\n~ f = greet_fn\n~ temp r = f(1)\n-> DONE\n",
5394        );
5395        let inference =
5396            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
5397        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
5398        assert!(
5399            diags
5400                .iter()
5401                .any(|d| d.code == DiagnosticCode::E066 && d.message.contains("temp `f`")),
5402            "{diags:?}"
5403        );
5404    }
5405
5406    #[test]
5407    fn global_fn_value_call_checks_never_surface_under_gradual() {
5408        let parsed = brink_syntax::parse(&format!(
5409            "{HEAL_GLOBAL}=== main ===\n~ temp r: int = heal_player(5, 6)\n-> DONE\n"
5410        ));
5411        let (hir, manifest, _diag) = brink_ir::hir::lower(FileId(0), &parsed.tree());
5412        let opts = crate::AnalysisOptions {
5413            dialect: crate::Dialect::Brink,
5414            // These tests TEST gradual behavior — explicit opt-out knob
5415            // (#1127: the brink dialect's implicit default is now strict).
5416            types: Some(TypePolicy::Gradual),
5417            ..crate::AnalysisOptions::default()
5418        };
5419        let result = crate::analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
5420        assert!(
5421            !result.diagnostics.iter().any(|d| matches!(
5422                d.code,
5423                DiagnosticCode::E063 | DiagnosticCode::E065 | DiagnosticCode::E066
5424            )),
5425            "gradual must never surface value-call checks: {:?}",
5426            result.diagnostics
5427        );
5428    }
5429
5430    #[test]
5431    fn strict_global_fn_value_mismatch_fires_through_the_real_pipeline() {
5432        // analyze_with_options -> finish_analysis -> whole_project_
5433        // diagnostics -> strict::check — the real production entry point
5434        // (`brink-compiler`/IDE), not just the `infer_project`/`check`
5435        // units above.
5436        let parsed = brink_syntax::parse(&format!(
5437            "{HEAL_GLOBAL}=== main ===\n~ temp r: int = heal_player(\"x\")\n-> DONE\n"
5438        ));
5439        let (hir, manifest, _diag) = brink_ir::hir::lower(FileId(0), &parsed.tree());
5440        let opts = crate::AnalysisOptions {
5441            dialect: crate::Dialect::Brink,
5442            types: Some(TypePolicy::Strict),
5443            ..crate::AnalysisOptions::default()
5444        };
5445        let result = crate::analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
5446        assert!(
5447            result
5448                .diagnostics
5449                .iter()
5450                .any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument 1")),
5451            "{:?}",
5452            result.diagnostics
5453        );
5454    }
5455
5456    // ── issue #628: list-literal global VAR carries its nominal LIST type
5457    //    (docs/typed-mode-spec.md §2/§5) ─────────────────────────────────
5458
5459    /// A VAR initialized directly to a list literal must infer its nominal
5460    /// `List<L>` type end-to-end, not collapse to `Unknown` (the phase-0
5461    /// `Sig` stub bug this issue reports). A temp assigned straight from
5462    /// such a VAR is the concrete, checkable consequence: before the fix,
5463    /// `weather`'s `Sig::value_type` fed `collect_globals` as `Ty::Unknown`
5464    /// (`infer::mod`'s `From<InferredType> for Ty` collapse), so `w` would
5465    /// spuriously trip the Unknown-escape check (`E065`) under strict even
5466    /// though its value is plainly a `List<Weathers>` — the same "resolved
5467    /// nominal type is clean" treatment `Ty::Struct`/`Ty::Handle` already
5468    /// get (`classify`'s doc above).
5469    #[test]
5470    fn list_literal_global_var_temp_is_clean_under_strict() {
5471        let (hir, index, res) = build(
5472            "LIST Weathers = sunny, rainy, snowy\n\
5473             VAR weather = (sunny)\n\
5474             === main ===\n~ temp w = weather\n-> DONE\n",
5475        );
5476        let inference =
5477            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
5478        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
5479        assert!(
5480            diags.is_empty(),
5481            "list-literal VAR's nominal type must flow through, not escape as Unknown: {diags:?}"
5482        );
5483    }
5484
5485    #[test]
5486    fn list_literal_global_var_is_clean_through_the_real_pipeline_under_strict() {
5487        // analyze_with_options -> finish_analysis -> whole_project_
5488        // diagnostics -> strict::check — the real production entry point
5489        // (`brink-compiler`/IDE), proving the fix is reachable outside the
5490        // unit-level `infer_project`/`check` harness too.
5491        let parsed = brink_syntax::parse(
5492            "LIST Weathers = sunny, rainy, snowy\n\
5493             VAR weather = (sunny)\n\
5494             === main ===\n~ temp w = weather\n-> DONE\n",
5495        );
5496        let (hir, manifest, _diag) = brink_ir::hir::lower(FileId(0), &parsed.tree());
5497        let opts = crate::AnalysisOptions {
5498            dialect: crate::Dialect::Brink,
5499            types: Some(TypePolicy::Strict),
5500            ..crate::AnalysisOptions::default()
5501        };
5502        let result = crate::analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
5503        assert!(
5504            !result
5505                .diagnostics
5506                .iter()
5507                .any(|d| d.code == DiagnosticCode::E065),
5508            "list-literal VAR must not escape as Unknown under strict: {:?}",
5509            result.diagnostics
5510        );
5511    }
5512
5513    // ── T1c follow-up (issue #733): call()/bind() explicit intrinsic forms
5514    //    wired into the same strict checker as direct calls / #fn (docs/
5515    //    t1c-spec.md §3/§4) ───────────────────────────────────────────────
5516
5517    #[test]
5518    fn well_typed_explicit_call_through_a_fn_value_is_clean_under_strict() {
5519        let (hir, index, res) = build(&format!(
5520            "{HEAL}=== main ===\n~ temp f = #fn(heal, player_hp)\n\
5521             ~ temp result: int = call(f, 5)\n-> DONE\n"
5522        ));
5523        let inference =
5524            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
5525        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
5526        assert!(diags.is_empty(), "{diags:?}");
5527    }
5528
5529    #[test]
5530    fn explicit_call_arity_mismatch_is_a_typed_mismatch_error() {
5531        let (hir, index, res) = build(&format!(
5532            "{HEAL}=== main ===\n~ temp f = #fn(heal, player_hp)\n\
5533             ~ temp r: int = call(f, 5, 6)\n-> DONE\n"
5534        ));
5535        let inference =
5536            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
5537        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
5538        assert!(
5539            diags
5540                .iter()
5541                .any(|d| d.code == DiagnosticCode::E063 && d.message.contains("2 argument")),
5542            "{diags:?}"
5543        );
5544    }
5545
5546    #[test]
5547    fn explicit_call_argument_type_mismatch_is_a_typed_mismatch_error() {
5548        let (hir, index, res) = build(&format!(
5549            "{HEAL}=== main ===\n~ temp f = #fn(heal, player_hp)\n\
5550             ~ temp r: int = call(f, \"lots\")\n-> DONE\n"
5551        ));
5552        let inference =
5553            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
5554        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
5555        assert!(
5556            diags
5557                .iter()
5558                .any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument 1")),
5559            "{diags:?}"
5560        );
5561    }
5562
5563    #[test]
5564    fn unknown_callee_in_explicit_call_is_an_escape_error() {
5565        let (hir, index, res) = build("=== main(g) ===\n~ temp r = call(g, 1)\n-> DONE\n");
5566        let inference =
5567            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
5568        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
5569        assert!(
5570            diags.iter().any(|d| d.code == DiagnosticCode::E065
5571                && d.message.contains("called as a function value")),
5572            "{diags:?}"
5573        );
5574    }
5575
5576    #[test]
5577    fn annotated_fn_typed_param_is_callable_through_explicit_call_under_strict() {
5578        // Same boundary-annotation firewall as the direct-call form (spec
5579        // §4), reached through `call(cb, …)` instead of `cb(…)`.
5580        let (hir, index, res) =
5581            build("=== function apply(cb: fn(int): int, x: int): int ===\n~ return call(cb, x)\n");
5582        let inference =
5583            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
5584        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
5585        assert!(diags.is_empty(), "{diags:?}");
5586    }
5587
5588    #[test]
5589    fn well_typed_bind_consumes_the_head_of_the_param_row() {
5590        // `bind` consumes only the head it's given (spec §3): `f`'s
5591        // remaining row is `fn(int): int` (`amount`); binding `5` leaves
5592        // `fn(): int`, callable with no further args.
5593        let (hir, index, res) = build(&format!(
5594            "{HEAL}=== main ===\n~ temp f = #fn(heal, player_hp)\n\
5595             ~ temp g = bind(f, 5)\n~ temp r: int = call(g)\n-> DONE\n"
5596        ));
5597        let inference =
5598            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
5599        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
5600        assert!(diags.is_empty(), "{diags:?}");
5601    }
5602
5603    #[test]
5604    fn over_binding_more_than_the_remaining_param_row_is_a_typed_mismatch_error() {
5605        // `f`'s remaining row has one param (`amount`); binding two is an
5606        // over-bind, not an arity mismatch — `bind` never truncates.
5607        let (hir, index, res) = build(&format!(
5608            "{HEAL}=== main ===\n~ temp f = #fn(heal, player_hp)\n\
5609             ~ temp g = bind(f, 5, 6)\n-> DONE\n"
5610        ));
5611        let inference =
5612            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
5613        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
5614        assert!(
5615            diags.iter().any(|d| d.code == DiagnosticCode::E063
5616                && d.message.contains("supplies 2")
5617                && d.message.contains("1 parameter")),
5618            "{diags:?}"
5619        );
5620    }
5621
5622    #[test]
5623    fn bind_argument_type_mismatch_is_a_typed_mismatch_error() {
5624        let (hir, index, res) = build(&format!(
5625            "{HEAL}=== main ===\n~ temp f = #fn(heal, player_hp)\n\
5626             ~ temp g = bind(f, \"lots\")\n-> DONE\n"
5627        ));
5628        let inference =
5629            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
5630        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
5631        assert!(
5632            diags
5633                .iter()
5634                .any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument 1")),
5635            "{diags:?}"
5636        );
5637    }
5638
5639    #[test]
5640    fn unknown_callee_in_bind_is_an_escape_error() {
5641        let (hir, index, res) = build("=== main(g) ===\n~ temp b = bind(g, 1)\n-> DONE\n");
5642        let inference =
5643            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
5644        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
5645        assert!(
5646            diags.iter().any(|d| d.code == DiagnosticCode::E065
5647                && d.message.contains("called as a function value")),
5648            "{diags:?}"
5649        );
5650    }
5651
5652    #[test]
5653    fn conflicted_callee_in_bind_is_a_conflicted_escape_error() {
5654        let (hir, index, res) = build(
5655            "=== main ===\n~ temp f = 1\n{f == \"x\":\n  no\n}\n~ temp b = bind(f, 1)\n-> DONE\n",
5656        );
5657        let inference =
5658            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
5659        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
5660        assert!(
5661            diags.iter().any(|d| d.code == DiagnosticCode::E066
5662                && d.message.contains("called as a function value")),
5663            "{diags:?}"
5664        );
5665    }
5666
5667    #[test]
5668    fn explicit_call_and_bind_checks_never_surface_under_gradual() {
5669        // The real production gate (mirrors `fn_value_call_checks_never_
5670        // surface_under_gradual`): `finish_analysis` only calls
5671        // `strict::check` under `types = strict` — `call`/`bind` stay
5672        // advisory under gradual, the runtime fault their backstop.
5673        let parsed = brink_syntax::parse(&format!(
5674            "{HEAL}=== main ===\n~ temp f = #fn(heal, player_hp)\n\
5675             ~ temp r: int = call(f, 5, 6)\n~ temp g = bind(f, \"lots\")\n-> DONE\n"
5676        ));
5677        let (hir, manifest, _diag) = brink_ir::hir::lower(FileId(0), &parsed.tree());
5678        let opts = crate::AnalysisOptions {
5679            dialect: crate::Dialect::Brink,
5680            // These tests TEST gradual behavior — explicit opt-out knob
5681            // (#1127: the brink dialect's implicit default is now strict).
5682            types: Some(TypePolicy::Gradual),
5683            ..crate::AnalysisOptions::default()
5684        };
5685        let result = crate::analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
5686        assert!(
5687            !result.diagnostics.iter().any(|d| matches!(
5688                d.code,
5689                DiagnosticCode::E063 | DiagnosticCode::E065 | DiagnosticCode::E066
5690            )),
5691            "gradual must never surface call()/bind() value-call checks: {:?}",
5692            result.diagnostics
5693        );
5694    }
5695
5696    #[test]
5697    fn strict_explicit_call_mismatch_fires_through_the_real_pipeline() {
5698        // analyze_with_options -> finish_analysis -> whole_project_
5699        // diagnostics -> strict::check — the wiring, not just the unit.
5700        let parsed = brink_syntax::parse(&format!(
5701            "{HEAL}=== main ===\n~ temp f = #fn(heal, player_hp)\n\
5702             ~ temp r: int = call(f, \"x\")\n-> DONE\n"
5703        ));
5704        let (hir, manifest, _diag) = brink_ir::hir::lower(FileId(0), &parsed.tree());
5705        let opts = crate::AnalysisOptions {
5706            dialect: crate::Dialect::Brink,
5707            types: Some(TypePolicy::Strict),
5708            ..crate::AnalysisOptions::default()
5709        };
5710        let result = crate::analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
5711        assert!(
5712            result
5713                .diagnostics
5714                .iter()
5715                .any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument 1")),
5716            "{:?}",
5717            result.diagnostics
5718        );
5719    }
5720
5721    #[test]
5722    fn strict_bind_over_bind_fires_through_the_real_pipeline() {
5723        let parsed = brink_syntax::parse(&format!(
5724            "{HEAL}=== main ===\n~ temp f = #fn(heal, player_hp)\n\
5725             ~ temp g = bind(f, 5, 6)\n-> DONE\n"
5726        ));
5727        let (hir, manifest, _diag) = brink_ir::hir::lower(FileId(0), &parsed.tree());
5728        let opts = crate::AnalysisOptions {
5729            dialect: crate::Dialect::Brink,
5730            types: Some(TypePolicy::Strict),
5731            ..crate::AnalysisOptions::default()
5732        };
5733        let result = crate::analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
5734        assert!(
5735            result
5736                .diagnostics
5737                .iter()
5738                .any(|d| d.code == DiagnosticCode::E063 && d.message.contains("supplies 2")),
5739            "{:?}",
5740            result.diagnostics
5741        );
5742    }
5743
5744    // ── TM-4b structs wiring (docs/typed-mode-spec.md §6) ──────────────
5745
5746    #[test]
5747    fn strict_check_wires_in_struct_construction_errors_through_the_real_pipeline() {
5748        // Exercises the full production path (`analyze_with_options` ->
5749        // `finish_analysis` -> `whole_project_diagnostics` ->
5750        // `strict::check` -> `crate::structs::check`), not `structs::check`
5751        // in isolation — proves the wiring, not just the unit.
5752        let parsed = brink_syntax::parse(
5753            "STRUCT Point = #{x: float, y: float}\n\
5754             === main ===\n~ p = Point#{x: 1.0}\n-> DONE\n",
5755        );
5756        let (hir, manifest, _diag) = brink_ir::hir::lower(FileId(0), &parsed.tree());
5757        let opts = crate::AnalysisOptions {
5758            dialect: crate::Dialect::Brink,
5759            types: Some(TypePolicy::Strict),
5760            ..crate::AnalysisOptions::default()
5761        };
5762        let result = crate::analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
5763        assert!(
5764            result
5765                .diagnostics
5766                .iter()
5767                .any(|d| d.code == DiagnosticCode::E069),
5768            "missing field must surface through the real strict pipeline: {:?}",
5769            result.diagnostics
5770        );
5771    }
5772
5773    // ── T1e-1 path projections (docs/t1e-spec.md §6, issue #831) ──────
5774
5775    #[test]
5776    fn strict_check_wires_in_ref_projection_segment_errors_through_the_real_pipeline() {
5777        // Same "exercises the full production path, not the unit in
5778        // isolation" rationale as the struct-construction test just above.
5779        let parsed = brink_syntax::parse(
5780            "STRUCT NPC = #{hp: int}\n\
5781             VAR npc: NPC = NPC#{hp: 10}\n\
5782             === function heal(ref hp, k) ===\n~ hp = hp + k\n\n\
5783             === main ===\n~ heal(ref npc.mana, 5)\n-> DONE\n",
5784        );
5785        let (hir, manifest, _diag) = brink_ir::hir::lower(FileId(0), &parsed.tree());
5786        let opts = crate::AnalysisOptions {
5787            dialect: crate::Dialect::Brink,
5788            types: Some(TypePolicy::Strict),
5789            ..crate::AnalysisOptions::default()
5790        };
5791        let result = crate::analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
5792        assert!(
5793            result
5794                .diagnostics
5795                .iter()
5796                .any(|d| d.code == DiagnosticCode::E098),
5797            "unknown field segment must surface through the real strict pipeline: {:?}",
5798            result.diagnostics
5799        );
5800    }
5801
5802    #[test]
5803    fn ref_projection_segment_errors_never_surface_under_gradual() {
5804        let parsed = brink_syntax::parse(
5805            "STRUCT NPC = #{hp: int}\n\
5806             VAR npc: NPC = NPC#{hp: 10}\n\
5807             === function heal(ref hp, k) ===\n~ hp = hp + k\n\n\
5808             === main ===\n~ heal(ref npc.mana, 5)\n-> DONE\n",
5809        );
5810        let (hir, manifest, _diag) = brink_ir::hir::lower(FileId(0), &parsed.tree());
5811        let opts = crate::AnalysisOptions {
5812            dialect: crate::Dialect::Brink,
5813            // These tests TEST gradual behavior — explicit opt-out knob
5814            // (#1127: the brink dialect's implicit default is now strict).
5815            types: Some(TypePolicy::Gradual),
5816            ..crate::AnalysisOptions::default()
5817        };
5818        let result = crate::analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
5819        assert!(
5820            !result
5821                .diagnostics
5822                .iter()
5823                .any(|d| d.code == DiagnosticCode::E098),
5824            "gradual must never surface ref-projection segment errors: {:?}",
5825            result.diagnostics
5826        );
5827    }
5828
5829    #[test]
5830    fn struct_construction_errors_never_surface_under_gradual() {
5831        let parsed = brink_syntax::parse(
5832            "STRUCT Point = #{x: float, y: float}\n\
5833             === main ===\n~ p = Point#{x: 1.0}\n-> DONE\n",
5834        );
5835        let (hir, manifest, _diag) = brink_ir::hir::lower(FileId(0), &parsed.tree());
5836        let opts = crate::AnalysisOptions {
5837            dialect: crate::Dialect::Brink,
5838            // These tests TEST gradual behavior — explicit opt-out knob
5839            // (#1127: the brink dialect's implicit default is now strict).
5840            types: Some(TypePolicy::Gradual),
5841            ..crate::AnalysisOptions::default()
5842        };
5843        let result = crate::analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
5844        assert!(
5845            !result
5846                .diagnostics
5847                .iter()
5848                .any(|d| d.code == DiagnosticCode::E069
5849                    || d.code == DiagnosticCode::E070
5850                    || d.code == DiagnosticCode::E071),
5851            "gradual must never surface construction errors: {:?}",
5852            result.diagnostics
5853        );
5854    }
5855
5856    // ── Issue #1995/#1920: `ref` parameter arguments are invariant ───────
5857
5858    /// The ruling's own worked example (#1995), a direct call: `scale`'s
5859    /// `ref x` parameter is declared `float`, and the caller passes a bare
5860    /// `int` cell (T1e §2: the `ref` sigil is only required to bind a
5861    /// *projection* like `npc.hp`; a bare durable-cell argument at a `ref`
5862    /// position needs no sigil — see `record_ref_param_writes`'s doc). No
5863    /// sigil means `arg_tys` infers `i` as an ordinary `Ty::Int` read, not
5864    /// the always-`Unknown` `Expr::RefArg` escape, so this exercises the
5865    /// checked path (`ref_assignable`), not the projection-typed one T1e
5866    /// deliberately leaves unchecked. `assignable(Float, Int)` is `true`
5867    /// (by-value widening would let this through), but a `ref` slot writes
5868    /// back through the caller's own storage — `ref_assignable` requires an
5869    /// exact match, so this is `E063` under strict.
5870    #[test]
5871    fn direct_call_ref_param_widening_is_rejected_under_strict() {
5872        let parsed = brink_syntax::parse(
5873            "=== function scale(ref x: float, k: int): float ===\n\
5874             ~ x = x * k\n~ return x\n\
5875             VAR i = 3\n\
5876             === main ===\n~ scale(i, 2)\n-> DONE\n",
5877        );
5878        let (hir, manifest, _diag) = brink_ir::hir::lower(FileId(0), &parsed.tree());
5879        let opts = crate::AnalysisOptions {
5880            dialect: crate::Dialect::Brink,
5881            types: Some(TypePolicy::Strict),
5882            ..crate::AnalysisOptions::default()
5883        };
5884        let result = crate::analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
5885        assert!(
5886            result
5887                .diagnostics
5888                .iter()
5889                .any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument 1")),
5890            "a ref argument's int cell must not widen into a declared-float ref \
5891             parameter: {:?}",
5892            result.diagnostics
5893        );
5894    }
5895
5896    /// The by-value sibling of the same call stays clean: passing an
5897    /// exactly-`float` cell into the `ref` slot, plus an `int` into `k`'s
5898    /// ordinary by-value `int` parameter, is unaffected by the invariant
5899    /// check above — it only rejects widening at the `ref` position.
5900    #[test]
5901    fn direct_call_by_value_param_is_unaffected_by_ref_invariance() {
5902        let (hir, index, res) = build(
5903            "=== function scale(ref x: float, k: int): float ===\n\
5904             ~ x = x * k\n~ return x\n\
5905             VAR f: float = 1.0\n\
5906             === main ===\n~ scale(f, 2)\n-> DONE\n",
5907        );
5908        let inference =
5909            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
5910        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
5911        assert!(
5912            !diags
5913                .iter()
5914                .any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument")),
5915            "a well-typed ref argument plus an exactly-typed by-value argument must stay \
5916             clean: {diags:?}"
5917        );
5918    }
5919
5920    /// The UFCS-desugared sibling (#1881/PR #1914): `i.scale()` desugars to
5921    /// `scale(i)` under D5's auto-ref (`scale`'s first parameter is `ref`),
5922    /// so `i`'s `int` receiver lands in the same `ref float` slot the
5923    /// direct-call test above exercises. Must reject uniformly — this is
5924    /// exactly the "same laxity in a different spelling" the ruling warns
5925    /// about.
5926    ///
5927    /// **Native, not ink** (rule 12c): UFCS's multi-segment callee-path
5928    /// shape (`ink_never_produces_a_multi_segment_callee_path`, this same
5929    /// module's `ufcs`-adjacent tests) is a `.brink`-only surface — an ink
5930    /// fixture's `i.scale()` never reaches `try_free_fn_desugar` at all, so
5931    /// this must go through `build_native`/`native_strict_diags`, not
5932    /// `build`/`brink_syntax::parse`.
5933    #[test]
5934    fn ufcs_ref_receiver_widening_is_rejected_under_strict() {
5935        let diags = native_strict_diags(
5936            "var i: int = 3;\n\
5937             fn scale(ref x: float): float {\n  x = x * 2.0;\n  return x;\n}\n\
5938             fn main() {\n  let r = i.scale();\n}\n",
5939        );
5940        assert!(
5941            diags
5942                .iter()
5943                .any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument 1")),
5944            "a UFCS auto-ref receiver's int cell must not widen into a declared-float \
5945             ref parameter either: {diags:?}"
5946        );
5947    }
5948
5949    /// Review finding on this issue's own PR (BLOCKING): the direct-call
5950    /// check's argument-mismatch fact used to be gated on
5951    /// `!self.arg_is_observed_local(arg)` for the whole check, not just the
5952    /// non-`ref` arm — which silently skipped a `ref` widening whenever the
5953    /// argument was a bare Param/Temp local, because `unify(Int, Float)`
5954    /// never goes `Conflicted`, so `observe`'s own join never reports it as
5955    /// `E066` either. `direct_call_ref_param_widening_is_rejected_under_
5956    /// strict` above only exercises a global `VAR` argument — the one
5957    /// argument kind the skip never covered — so it passed identically with
5958    /// this exact soundness hole still open. This is the "same laxity in a
5959    /// different spelling" the ruling warns about, on the **native** local
5960    /// (`let`) spelling.
5961    #[test]
5962    fn direct_call_ref_param_widening_through_a_local_is_rejected_under_strict() {
5963        let diags = native_strict_diags(
5964            "fn scale(ref x: float): float {\n  x = x * 2.0;\n  return x;\n}\n\
5965             fn main() {\n  let i: int = 3;\n  scale(i);\n}\n",
5966        );
5967        assert!(
5968            diags
5969                .iter()
5970                .any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument 1")),
5971            "a bare local int cell must not widen into a declared-float ref parameter \
5972             either, the same as the global-VAR case above: {diags:?}"
5973        );
5974    }
5975
5976    /// The **ink** sibling of the test above: a `~ temp` argument at a `ref`
5977    /// position must be checked the same way a native `let` local is.
5978    #[test]
5979    fn direct_call_ref_param_widening_through_an_ink_temp_is_rejected_under_strict() {
5980        let parsed = brink_syntax::parse(
5981            "=== function scale(ref x: float, k: int): float ===\n\
5982             ~ x = x * k\n~ return x\n\
5983             === main ===\n~ temp i: int = 3\n~ scale(i, 2)\n-> DONE\n",
5984        );
5985        let (hir, manifest, _diag) = brink_ir::hir::lower(FileId(0), &parsed.tree());
5986        let opts = crate::AnalysisOptions {
5987            dialect: crate::Dialect::Brink,
5988            types: Some(TypePolicy::Strict),
5989            ..crate::AnalysisOptions::default()
5990        };
5991        let result = crate::analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
5992        assert!(
5993            result
5994                .diagnostics
5995                .iter()
5996                .any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument 1")),
5997            "a `~ temp` int cell must not widen into a declared-float ref parameter \
5998             either: {:?}",
5999            result.diagnostics
6000        );
6001    }
6002
6003    // ── Issue #2001: `#fn` creation-site ref-invariance ───────────────────
6004
6005    /// Repro from #2001 (the tracked remainder of #1995/#1920 left after PR
6006    /// #1999): `#fn(target, args…)`'s bound-argument loop
6007    /// (`InferPass::infer_fn_literal`) never ran *any* argument-type check —
6008    /// neither `ref_assignable` nor `assignable` — even though this literal
6009    /// **is** the by-ref binding site (docs/t1c-spec.md §2: "all `ref`
6010    /// params must be bound at creation"), the one place a `Ty::Fn` value's
6011    /// remaining param row can never contain a `ref` param. `#fn`'s own
6012    /// `fn_values::check` (`E080`) only checks that a `ref` position is
6013    /// bound to *some* durable cell — never that the cell's static type
6014    /// agrees with the declared `ref` param type — so this is a genuinely
6015    /// separate gap from that check. Ink-only fixture (`#fn`'s binding form
6016    /// is ink-only, ruled 2026-08-01 per #1862).
6017    #[test]
6018    fn fn_literal_ref_param_widening_is_rejected_under_strict() {
6019        let parsed = brink_syntax::parse(
6020            "=== function scale(ref x: float, k: int): float ===\n\
6021             ~ x = x * k\n~ return x\n\
6022             VAR i = 3\n\
6023             === main ===\n~ temp f = #fn(scale, i)\n~ temp r: float = f(2)\n-> DONE\n",
6024        );
6025        let (hir, manifest, _diag) = brink_ir::hir::lower(FileId(0), &parsed.tree());
6026        let opts = crate::AnalysisOptions {
6027            dialect: crate::Dialect::Brink,
6028            types: Some(TypePolicy::Strict),
6029            ..crate::AnalysisOptions::default()
6030        };
6031        let result = crate::analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
6032        assert!(
6033            result
6034                .diagnostics
6035                .iter()
6036                .any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument 1")),
6037            "a #fn-bound int cell must not widen into a declared-float ref parameter \
6038             either: {:?}",
6039            result.diagnostics
6040        );
6041    }
6042
6043    /// The by-value sibling: binding an ordinary (non-`ref`) `int` argument
6044    /// into `k` alongside an exactly-typed `ref float` binding must stay
6045    /// clean — the invariant check only rejects widening at the `ref`
6046    /// position, and #2001 explicitly declines to add a *new* by-value
6047    /// check at this creation site (that is its own scope call per the
6048    /// issue body, not assumed yes).
6049    #[test]
6050    fn fn_literal_by_value_param_is_unaffected_by_ref_invariance() {
6051        let (hir, index, res) = build(
6052            "=== function scale(ref x: float, k: int): float ===\n\
6053             ~ x = x * k\n~ return x\n\
6054             VAR f: float = 1.0\n\
6055             === main ===\n~ temp fv = #fn(scale, f, 2)\n-> DONE\n",
6056        );
6057        let inference =
6058            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
6059        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
6060        assert!(
6061            !diags
6062                .iter()
6063                .any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument")),
6064            "a well-typed ref argument plus an exactly-typed by-value argument bound at \
6065             creation must stay clean: {diags:?}"
6066        );
6067    }
6068
6069    // ── Issue #2127: divert-with-args (`-> knot(args)`) `ref`-position ────
6070    //    argument checking
6071
6072    /// Repro from #2127: `InferPass::infer_target` (the `-> knot(args)`
6073    /// divert-with-args site) computed `arg_tys` and then explicitly
6074    /// discarded it (`let _ = arg_tys;`) — it called
6075    /// `record_ref_param_writes` so a `ref` param's *write* was tracked for
6076    /// effect purposes, but never compared the argument's static type
6077    /// against the declared param type in either direction. Same shape as
6078    /// `direct_call_ref_param_widening_is_rejected_under_strict` above: a
6079    /// bare `int` `VAR` cell must not widen into a declared-`float` `ref`
6080    /// parameter, this time reached via a divert rather than a call
6081    /// expression. Uses a plain (non-`function`) knot — `-> ` is the
6082    /// ordinary way to reach one, unlike `scale(...)`'s call-expression
6083    /// sibling tests, which exercise a `function` knot.
6084    #[test]
6085    fn divert_target_ref_param_widening_is_rejected_under_strict() {
6086        let parsed = brink_syntax::parse(
6087            "=== scale(ref x: float, k: int) ===\n\
6088             ~ x = x * k\n-> DONE\n\
6089             VAR i = 3\n\
6090             === main ===\n-> scale(i, 2)\n",
6091        );
6092        let (hir, manifest, _diag) = brink_ir::hir::lower(FileId(0), &parsed.tree());
6093        let opts = crate::AnalysisOptions {
6094            dialect: crate::Dialect::Brink,
6095            types: Some(TypePolicy::Strict),
6096            ..crate::AnalysisOptions::default()
6097        };
6098        let result = crate::analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
6099        assert!(
6100            result
6101                .diagnostics
6102                .iter()
6103                .any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument 1")),
6104            "a divert-with-args int cell must not widen into a declared-float ref \
6105             parameter either: {:?}",
6106            result.diagnostics
6107        );
6108    }
6109
6110    /// Review finding (BLOCKING) on this issue's own PR: the test above only
6111    /// exercises a global `VAR` argument — per the precedent set on the
6112    /// sibling direct-call check (`direct_call_ref_param_widening_is_
6113    /// rejected_under_strict`'s own doc, and the review finding that
6114    /// produced `direct_call_ref_param_widening_through_a_local_is_
6115    /// rejected_under_strict`/`..._through_an_ink_temp_...` above), a global
6116    /// `VAR` is "the one argument kind the observed-local skip never
6117    /// covered" — `arg_is_observed_local` only recognizes a bare
6118    /// Param/Temp local, not a `VAR`. Rewriting the new guard as
6119    /// `!observed && !ref_assignable(...)` (dropping the `(!observed ||
6120    /// assignable(...))` carve-out this fix mirrors from `infer_call`)
6121    /// would leave the VAR-only test above green, since a VAR argument is
6122    /// never "observed" in the first place. This is the divert-target
6123    /// sibling of `direct_call_ref_param_widening_through_an_ink_temp_is_
6124    /// rejected_under_strict`: a `~ temp` local (not a global VAR) at the
6125    /// `ref` position must still be rejected.
6126    #[test]
6127    fn divert_target_ref_param_widening_through_an_ink_temp_is_rejected_under_strict() {
6128        let parsed = brink_syntax::parse(
6129            "=== scale(ref x: float, k: int) ===\n\
6130             ~ x = x * k\n-> DONE\n\
6131             === main ===\n~ temp i: int = 3\n-> scale(i, 2)\n",
6132        );
6133        let (hir, manifest, _diag) = brink_ir::hir::lower(FileId(0), &parsed.tree());
6134        let opts = crate::AnalysisOptions {
6135            dialect: crate::Dialect::Brink,
6136            types: Some(TypePolicy::Strict),
6137            ..crate::AnalysisOptions::default()
6138        };
6139        let result = crate::analyze_with_options(&[(FileId(0), &hir, &manifest)], &opts);
6140        assert!(
6141            result
6142                .diagnostics
6143                .iter()
6144                .any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument 1")),
6145            "a `~ temp` int cell must not widen into a declared-float ref parameter \
6146             at a divert target either: {:?}",
6147            result.diagnostics
6148        );
6149    }
6150
6151    /// The by-value sibling: an exactly-typed `ref float` argument alongside
6152    /// a `float`-declared by-value param `k` fed an `int` literal must stay
6153    /// clean — #2127 deliberately leaves by-value divert-target argument
6154    /// checking unimplemented (its own design call, same posture #2001 took
6155    /// for `infer_fn_literal`), so this proves the `ref`-only check doesn't
6156    /// spuriously fire on the by-value position either.
6157    ///
6158    /// Review finding (BLOCKING) on this issue's own PR: the original
6159    /// fixture passed the int literal `2` into `k: int` — an exact match
6160    /// that stays clean whether by-value positions are unchecked (actual),
6161    /// checked covariantly, or checked invariantly, so it could not
6162    /// distinguish any of those. `k` is declared `float` here instead
6163    /// (still fed the int literal `2`): `assignable(Float, Int)` is `true`
6164    /// (the covariant widening direction) but `ref_assignable(Float, Int)`
6165    /// is `false`, so this fixture is clean today under the actual
6166    /// (by-value-unchecked) behavior and goes red the moment ref
6167    /// invariance ever leaks into a by-value slot.
6168    #[test]
6169    fn divert_target_by_value_param_is_unaffected_by_ref_invariance() {
6170        let (hir, index, res) = build(
6171            "=== scale(ref x: float, k: float) ===\n\
6172             ~ x = x * k\n-> DONE\n\
6173             VAR f: float = 1.0\n\
6174             === main ===\n-> scale(f, 2)\n",
6175        );
6176        let inference =
6177            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
6178        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
6179        assert!(
6180            !diags
6181                .iter()
6182                .any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument")),
6183            "a well-typed ref argument plus a covariantly-widened by-value argument at a \
6184             divert target must stay clean: {diags:?}"
6185        );
6186    }
6187
6188    /// The `root_content` sibling of the test above (mirrors
6189    /// `ink_root_content_direct_call_ref_widening_is_checked`): a divert
6190    /// with a ref-mismatched argument written at an ink file's literal
6191    /// top-level weave must still reach a diagnostic through
6192    /// `check_direct_call_args`'s existing `root_content` synthetic-ID
6193    /// handling (issue #1903) — this fix pushes onto the same
6194    /// `direct_call_arg_mismatches` vec every other producer already uses,
6195    /// so no additional plumbing should be needed, but this proves it.
6196    #[test]
6197    fn ink_root_content_divert_target_ref_widening_is_checked() {
6198        let src = "VAR i = 3\n\
6199                   -> scale(i, 2)\n\
6200                   === scale(ref x: float, k: int) ===\n\
6201                   ~ x = x * k\n-> DONE\n";
6202        let (hir, index, res) = build(src);
6203        assert!(
6204            !hir.root_content.stmts.is_empty(),
6205            "fixture precondition: the ink frontend must populate root_content"
6206        );
6207        let inference =
6208            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
6209        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
6210        assert!(
6211            diags
6212                .iter()
6213                .any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument 1")),
6214            "a divert-with-args ref-argument mismatch written at file root must be \
6215             reported, not silently dropped: {diags:?}"
6216        );
6217    }
6218
6219    /// Issue #2136: before native's `hir::lower_native::body::lower_
6220    /// divert_target` wired `-> knot(args)` call args into
6221    /// `DivertTarget::args`, this exact fixture failed to *compile* at all
6222    /// (a hard `E129`, "parses but has no HIR lowering yet") — PR #2128's
6223    /// review disposition confirmed directly that #2127/#2128's ref-
6224    /// position check therefore had structurally nothing to check on the
6225    /// native surface, since `target.args` was always empty by the time
6226    /// this pass ran. Now the arg survives lowering and reaches
6227    /// `infer_target` exactly like the ink-dialect fixture above — this is
6228    /// the native sibling of `divert_target_ref_param_widening_is_
6229    /// rejected_under_strict`, proving #2127/#2128's existing check now
6230    /// fires on a native fixture with no changes to `brink-analyzer`
6231    /// itself.
6232    #[test]
6233    fn divert_target_ref_param_widening_is_rejected_under_strict_on_native() {
6234        let diags = native_strict_diags(
6235            "fn scale(ref x: float, k: int) {\n  x = x * k;\n}\n\
6236             var i: int = 3;\n\
6237             flow main() {\n  -> scale(i, 2)\n}\n",
6238        );
6239        assert!(
6240            diags
6241                .iter()
6242                .any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument 1")),
6243            "a divert-with-args int cell must not widen into a declared-float ref \
6244             parameter on native either: {diags:?}"
6245        );
6246    }
6247
6248    // ── Review finding on #2001: root_content reaches check_direct_call_args ──
6249
6250    /// Review finding (BLOCKING) on this issue's own PR: `check_direct_call_args`
6251    /// built `def_ids` from `hir.knots` + stitches only, never gaining the
6252    /// #1903 `root_content` synthetic-ID block its structurally parallel
6253    /// sibling `check_typed_assign_mismatches` has — so a direct-call
6254    /// argument-type mismatch written at an ink file's literal top-level
6255    /// weave was recorded by inference but silently dropped by strict,
6256    /// never reaching a diagnostic. Mirrors
6257    /// `ink_root_content_declared_temp_init_is_checked` above, but for
6258    /// `check_direct_call_args`'s own fact kind, and MUST fail with the
6259    /// `check_direct_call_args` `root_content` block reverted.
6260    #[test]
6261    fn ink_root_content_direct_call_ref_widening_is_checked() {
6262        let src = "VAR i = 3\n\
6263                   ~ scale(i, 2)\nHello.\n-> END\n\
6264                   === function scale(ref x: float, k: int): float ===\n\
6265                   ~ x = x * k\n~ return x\n";
6266        let (hir, index, res) = build(src);
6267        assert!(
6268            !hir.root_content.stmts.is_empty(),
6269            "fixture precondition: the ink frontend must populate root_content"
6270        );
6271        let inference =
6272            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
6273        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
6274        assert!(
6275            diags
6276                .iter()
6277                .any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument 1")),
6278            "a direct-call ref-argument mismatch written at file root must be \
6279             reported, not silently dropped: {diags:?}"
6280        );
6281    }
6282
6283    /// The `#fn` creation-site sibling of the test above, in `root_content` —
6284    /// same gap, same fix, same fact kind #2001 introduced.
6285    #[test]
6286    fn ink_root_content_fn_literal_ref_widening_is_checked() {
6287        let src = "VAR i = 3\n\
6288                   ~ temp f = #fn(scale, i)\nHello.\n-> END\n\
6289                   === function scale(ref x: float, k: int): float ===\n\
6290                   ~ x = x * k\n~ return x\n";
6291        let (hir, index, res) = build(src);
6292        assert!(
6293            !hir.root_content.stmts.is_empty(),
6294            "fixture precondition: the ink frontend must populate root_content"
6295        );
6296        let inference =
6297            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
6298        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
6299        assert!(
6300            diags
6301                .iter()
6302                .any(|d| d.code == DiagnosticCode::E063 && d.message.contains("argument 1")),
6303            "a #fn-bound ref-argument mismatch written at file root must be \
6304             reported, not silently dropped: {diags:?}"
6305        );
6306    }
6307
6308    // ── Issue #1903: `root_content` reaches the strict walk ──────────────
6309
6310    /// Issue #1903 regression. `collect_defs` walked only `hir.knots`, so
6311    /// `root_content` — which the **ink** frontend populates from the file's
6312    /// literal top-level weave — never reached inference, and a declared-type
6313    /// violation written at file root was silently unchecked.
6314    ///
6315    /// ⚠ **Note the dialect.** This fixture is ink, not native, and that is
6316    /// load-bearing rather than incidental: `lower_native::entry_root_content`
6317    /// makes a `.brink` file's `root_content` either empty or a *single
6318    /// synthesized `Divert`* to `main`, never user statements. So native
6319    /// root content holds nothing type-bearing and cannot exercise this path
6320    /// at all — a `.brink` fixture would pass identically with the fix
6321    /// reverted. See this test's companion,
6322    /// [`native_root_content_holds_no_type_bearing_statements`].
6323    #[test]
6324    fn ink_root_content_declared_temp_init_is_checked() {
6325        let src = "~ temp n: int = \"hello\"\nHello.\n-> END\n";
6326        let (hir, index, res) = build(src);
6327        assert!(
6328            !hir.root_content.stmts.is_empty(),
6329            "fixture precondition: the ink frontend must populate root_content"
6330        );
6331        let inference =
6332            crate::infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
6333        let diags = check(&[(FileId(0), &hir)], &index, &inference, &res, None);
6334        assert!(
6335            diags.iter().any(|d| d.code == DiagnosticCode::E063),
6336            "a declared-type violation at file root must be reported: {diags:?}"
6337        );
6338    }
6339
6340    /// Pins the asymmetry the test above depends on: a native file's
6341    /// `root_content` is only ever the synthesized entry `Divert`, so #1903's
6342    /// walk finds no assignment or temp there. If native ever grows real
6343    /// file-root statements this test fails, which is the signal to add a
6344    /// native counterpart of the test above.
6345    #[test]
6346    fn native_root_content_holds_no_type_bearing_statements() {
6347        let (hir, _index, _res) = build_native("flow main() {\n  Hello.\n}\n");
6348        assert_eq!(hir.root_content.stmts.len(), 1);
6349        assert!(
6350            matches!(hir.root_content.stmts[0], brink_ir::Stmt::Divert(_)),
6351            "native root_content must be the synthesized entry divert, got {:?}",
6352            hir.root_content.stmts[0]
6353        );
6354    }
6355}