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