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