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(¬ify_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}