From 9d4456491570079e12d285398610fe5319b2a063 Mon Sep 17 00:00:00 2001 From: Antonio Sarosi Date: Wed, 19 Aug 2026 16:07:04 +0200 Subject: [PATCH 1/4] Extend the runtime-type escape rule to throws and optional chains --- .../baml_compiler2_hir_ty/src/diagnostics.rs | 16 +- .../crates/baml_compiler2_hir_ty/src/infer.rs | 121 ++++- .../src/runtime_type.rs | 52 ++ .../crates/baml_lsp2_actions/src/check.rs | 15 +- .../baml_tests/tests/runtime_type_escape.rs | 480 +++++++++++++++++- 5 files changed, 647 insertions(+), 37 deletions(-) diff --git a/baml_language/crates/baml_compiler2_hir_ty/src/diagnostics.rs b/baml_language/crates/baml_compiler2_hir_ty/src/diagnostics.rs index 81d9e00682d..50c1a6c27c1 100644 --- a/baml_language/crates/baml_compiler2_hir_ty/src/diagnostics.rs +++ b/baml_language/crates/baml_compiler2_hir_ty/src/diagnostics.rs @@ -17,7 +17,7 @@ use std::fmt; use baml_base::{FileId, Name, SourceFile}; use baml_compiler_diagnostics::runtime_type::{ - RuntimeTypeNameRewrite, runtime_type_must_be_named_help, + RuntimeTypeEscape, RuntimeTypeNameRewrite, runtime_type_must_be_named_help, }; use baml_compiler2_ast::{AstSourceMap, ExprId, StmtId, TypeAnnotId}; use baml_compiler2_hir::{ @@ -137,11 +137,13 @@ pub enum TirTypeError { /// slots require the whole-slot `unreflect(value)` marker. ComputedGenericArgumentRequiresUnreflect { name: Name }, /// An inline `unreflect(value)` type argument would escape its call: the - /// runtime parameter is rigid for that one call, but the callee's result - /// type still mentions it, so the value the call produces would carry a - /// name that no longer means anything. The lexical `type T = unreflect(v)` - /// binding is the spelling that outlives a call. - RuntimeTypeMustBeNamed, + /// runtime parameter is rigid for that one call, but a type the call + /// publishes still mentions it, so the value or error that comes back + /// would carry a name that no longer means anything. The lexical + /// `type T = unreflect(v)` binding is the spelling that outlives a call. + /// `escape` picks the note; the headline and the fix are the same either + /// way. + RuntimeTypeMustBeNamed { escape: RuntimeTypeEscape }, /// A mounted callable whose implementation is compiler-owned and has no /// location-free link ABI was invoked from a source-less consumer. MountedPackageCallUnsupported { path: Name }, @@ -892,7 +894,7 @@ impl fmt::Display for TirTypeError { ); f.write_str(diagnostic.message.as_str()) } - TirTypeError::RuntimeTypeMustBeNamed => { + TirTypeError::RuntimeTypeMustBeNamed { .. } => { let diagnostic = baml_compiler_diagnostics::runtime_type::runtime_type_must_be_named(); f.write_str(diagnostic.message.as_str()) diff --git a/baml_language/crates/baml_compiler2_hir_ty/src/infer.rs b/baml_language/crates/baml_compiler2_hir_ty/src/infer.rs index 376b1018aaa..eae92c6636d 100644 --- a/baml_language/crates/baml_compiler2_hir_ty/src/infer.rs +++ b/baml_language/crates/baml_compiler2_hir_ty/src/infer.rs @@ -24,6 +24,7 @@ pub mod unify; use std::{cell::RefCell, path::PathBuf, sync::Arc}; +use baml_compiler_diagnostics::runtime_type::RuntimeTypeEscape; use baml_compiler2_ast::{ Expr, ExprBody, ExprId, ObjectExprField, PatId, Pattern, PropertySyntax, Stmt, StmtId, traverse::BodyNode, @@ -730,10 +731,11 @@ enum PendingDiag<'db> { expr: ExprId, }, /// An inline `unreflect(carrier)` slot whose rigid parameter survives into - /// `enclosing`'s published type. + /// `enclosing`'s published type — as its value or as its error. RuntimeTypeMustBeNamed { carrier: ExprId, enclosing: ExprId, + escape: RuntimeTypeEscape, }, CannotConstructReflectionKind { expr: ExprId, @@ -2486,6 +2488,7 @@ impl<'db> InferenceContext<'db> { let ty = self.infer_expr(body, *inner, &Expectation::None); let nullable = self.chain_nullable.pop().expect("pushed above"); if nullable { + self.report_chain_null_escape(body, *inner); self.union_of(&[ty, Ty::null()]) } else { ty @@ -7077,7 +7080,16 @@ impl<'db> InferenceContext<'db> { if runtime_params.is_empty() { return; } - self.report_runtime_type_escape(call, &Ty::from_plain(&function.return_type)); + self.report_runtime_type_escape( + call, + &Ty::from_plain(&function.return_type), + RuntimeTypeEscape::Value, + ); + self.report_runtime_type_escape( + call, + &Ty::from_plain(&function.callable_throws), + RuntimeTypeEscape::Error, + ); let mut dependent = FxHashMap::default(); for (param_index, param) in function .params @@ -7263,22 +7275,44 @@ impl<'db> InferenceContext<'db> { /// BEP-066 ruling (A): an inline `unreflect(value)` type argument is legal /// only while the runtime type stays out of the expression's published /// type. The parameter is rigid for this call alone — the call site - /// publishes `occurrence_ty` in its place — so a result that still + /// publishes `occurrence_ty` in its place — so a published type that still /// mentions the parameter would be typed by a substitution the value does /// not actually satisfy afterwards, and every later dispatch re-derives /// the receiver's arguments from that published type. /// - /// The exception, and the reason this is an occurs-check on the RESULT - /// rather than a ban on the spelling, is a result that IS the parameter + /// The exception, and the reason this is an occurs-check on the published + /// type rather than a ban on the spelling, is a type that IS the parameter /// (`parse(..) -> T`): occurrence-substitution then types a VALUE, the /// runtime tag rides on the value itself, and nothing static claims more /// than `unknown`. That is the supported dynamic path and stays legal. /// One position deeper — `Wrapper`, `T[]`, `T?`, a constructed `C` — /// the occurrence substitutes into a type CONSTRUCTOR, and the published /// type starts asserting something about the value. - fn report_runtime_type_escape(&mut self, call: ExprId, declared_ret: &Ty) { - let escaping: Vec = self - .result + /// + /// A call publishes two types, and the rule reads the same in both: the + /// result the caller binds, and the [`RuntimeTypeEscape::Error`] a + /// `throws` clause hands to the caller's handler — which is just as + /// visible after the call returns, and just as often written `Boom`. + fn report_runtime_type_escape( + &mut self, + call: ExprId, + published: &Ty, + escape: RuntimeTypeEscape, + ) { + let escaping = self.escaping_carriers(call, |parameter| { + runtime_param_escapes(published, parameter) + }); + self.report_escaping_carriers(call, escaping, escape); + } + + /// The carrier expressions of `call`'s inline `unreflect(...)` slots whose + /// parameter `escapes`. + fn escaping_carriers( + &self, + call: ExprId, + escapes: impl Fn(&baml_type::ParamTy) -> bool, + ) -> Vec { + self.result .call_plans .get(&call) .into_iter() @@ -7286,23 +7320,56 @@ impl<'db> InferenceContext<'db> { .filter_map(|slot| match slot { CallTypeArgPlan::Runtime { operand, parameter, .. - } => runtime_param_escapes_result(declared_ret, parameter).then_some(*operand), + } => escapes(parameter).then_some(*operand), CallTypeArgPlan::Static { .. } => None, }) - .collect(); - for carrier in escaping { + .collect() + } + + fn report_escaping_carriers( + &mut self, + enclosing: ExprId, + carriers: Vec, + escape: RuntimeTypeEscape, + ) { + for carrier in carriers { // A callee can be typed more than once (the interface probe - // re-runs the member road); the slot is reported once. + // re-runs the member road), and a slot can escape through more + // than one published type; the slot is reported once. if self.reported_runtime_escapes.insert(carrier) { self.pending_diags .push(PendingDiag::RuntimeTypeMustBeNamed { carrier, - enclosing: call, + enclosing, + escape, }); } } } + /// The `?.` arm of the same rule, reported at the chain BOUNDARY because + /// that is where the wrapper appears: a short-circuiting chain republishes + /// its tail's result as `T | null`, so a call whose bare `-> T` result was + /// legal on its own stops being legal once `?.` wraps it. This is the + /// spelling half of the declared `-> T?` refusal — both publish `unknown?` + /// and both now say the same thing. + /// + /// Only the chain's TAIL is affected: it is the expression whose value the + /// chain republishes. A call in argument position, or one whose result is + /// consumed further along the chain (`a?.b.m().field`), + /// publishes its own result unchanged and keeps whatever verdict its + /// signature earned. + fn report_chain_null_escape(&mut self, body: &ExprBody, tail: ExprId) { + if !matches!( + body.exprs[tail], + Expr::Call { .. } | Expr::OptionalCall { .. } + ) { + return; + } + let escaping = self.escaping_carriers(tail, |_| true); + self.report_escaping_carriers(tail, escaping, RuntimeTypeEscape::Value); + } + fn record_runtime_dependent_arguments( &mut self, call: ExprId, @@ -7316,7 +7383,12 @@ impl<'db> InferenceContext<'db> { }) { return; } - self.report_runtime_type_escape(call, &signature.ret); + self.report_runtime_type_escape(call, &signature.ret, RuntimeTypeEscape::Value); + // `signature.throws` is the DECLARED clause when the author wrote one + // and the inferred effect otherwise (S12). Both are published to the + // caller, so both are checked — the note is worded for a clause the + // author may never have spelled. + self.report_runtime_type_escape(call, &signature.throws, RuntimeTypeEscape::Error); let runtime_params = self.runtime_call_params(call); if runtime_params.is_empty() { return; @@ -9534,9 +9606,13 @@ impl<'db> InferenceContext<'db> { PendingDiag::RuntimeTypeArgumentOnIndirectCall { expr } => { (TirTypeError::RuntimeTypeArgumentOnIndirectCall, expr) } - PendingDiag::RuntimeTypeMustBeNamed { carrier, enclosing } => { + PendingDiag::RuntimeTypeMustBeNamed { + carrier, + enclosing, + escape, + } => { diags.push(TirDiagnostic { - error: TirTypeError::RuntimeTypeMustBeNamed, + error: TirTypeError::RuntimeTypeMustBeNamed { escape }, severity: DiagnosticSeverity::Error, primary: DiagnosticLocation::UnreflectArg { carrier, enclosing }, related: Vec::new(), @@ -11046,14 +11122,15 @@ fn interface_mentions_param(interface: &InterfaceRef, param: &baml_type::ParamTy .any(|ty| ty_mentions_param(ty, param)) } -/// Does a call-scoped runtime parameter survive into `ret` as more than the -/// result itself? See [`InferCtx::report_runtime_type_escape`] for why the -/// bare-parameter result is the one shape that does not escape. -fn runtime_param_escapes_result(ret: &Ty, param: &baml_type::ParamTy) -> bool { - if matches!(ret.kind(), TyKind::TypeVar(candidate, _) if candidate == param) { +/// Does a call-scoped runtime parameter survive into a type the call +/// `published` (its result, or the error it can throw) as more than that type +/// itself? See [`InferCtx::report_runtime_type_escape`] for why the bare +/// parameter is the one shape that does not escape. +fn runtime_param_escapes(published: &Ty, param: &baml_type::ParamTy) -> bool { + if matches!(published.kind(), TyKind::TypeVar(candidate, _) if candidate == param) { return false; } - ty_mentions_param(ret, param) + ty_mentions_param(published, param) } fn interface_occurrence_ty(interface: &InterfaceRef) -> Ty { diff --git a/baml_language/crates/baml_compiler_diagnostics/src/runtime_type.rs b/baml_language/crates/baml_compiler_diagnostics/src/runtime_type.rs index dd9f0c7f7e1..19c395a2934 100644 --- a/baml_language/crates/baml_compiler_diagnostics/src/runtime_type.rs +++ b/baml_language/crates/baml_compiler_diagnostics/src/runtime_type.rs @@ -295,6 +295,39 @@ pub const RUNTIME_TYPE_MUST_BE_NAMED_NOTE: &str = concat!( "`unreflect(...)`, but the value this expression creates would still need it afterwards", ); +/// E0168 — the same explanation for the error channel. A `throws` clause +/// publishes the type just as a result does, and the caller reads it after the +/// call has returned. Worded for a clause the author may never have written: +/// an undeclared `throws` is inferred from the body, so this note has to make +/// sense with no `throws` in sight. +pub const RUNTIME_TYPE_MUST_BE_NAMED_THROWS_NOTE: &str = concat!( + "a type created at runtime only lasts for one call when written inline with ", + "`unreflect(...)`, but the error this call can throw would still need it afterwards", +); + +/// Which published type an inline `unreflect(...)` slot escaped into. Only the +/// note differs: the headline is the same complaint and the suggested rewrite +/// is the same fix either way. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum RuntimeTypeEscape { + /// The value the expression produces — a result type that wraps the + /// parameter, a class literal, or a `?.` chain that republishes the + /// call's result as a nullable. + Value, + /// The error the call can throw. + Error, +} + +impl RuntimeTypeEscape { + /// The note printed at the `unreflect(...)` slot. + pub fn note(self) -> &'static str { + match self { + Self::Value => RUNTIME_TYPE_MUST_BE_NAMED_NOTE, + Self::Error => RUNTIME_TYPE_MUST_BE_NAMED_THROWS_NOTE, + } + } +} + /// E0168 — an inline `unreflect(value)` type argument would escape its call. pub fn runtime_type_must_be_named() -> Diagnostic { Diagnostic::error( @@ -566,6 +599,25 @@ mod tests { } } + #[test] + fn runtime_type_notes_name_the_published_type_that_would_outlive_the_call() { + // One complaint, one fix; the tail of the note is the only thing the + // reader needs to be told apart, and it names what they wrote. + let (value, error) = ( + RuntimeTypeEscape::Value.note(), + RuntimeTypeEscape::Error.note(), + ); + assert!( + value.ends_with("but the value this expression creates would still need it afterwards") + ); + assert!( + error.ends_with("but the error this call can throw would still need it afterwards") + ); + let shared = "a type created at runtime only lasts for one call when written inline with `unreflect(...)`, "; + assert!(value.starts_with(shared)); + assert!(error.starts_with(shared)); + } + #[test] fn runtime_type_help_uses_the_authors_own_spelling_and_degrades_cleanly() { assert_eq!( diff --git a/baml_language/crates/baml_lsp2_actions/src/check.rs b/baml_language/crates/baml_lsp2_actions/src/check.rs index 8d542b95439..8633acdc64b 100644 --- a/baml_language/crates/baml_lsp2_actions/src/check.rs +++ b/baml_language/crates/baml_lsp2_actions/src/check.rs @@ -1212,13 +1212,14 @@ fn new_tir_diagnostic( .with_primary_span(span) .with_phase(DiagnosticPhase::Type); } - if matches!(error, TirTypeError::RuntimeTypeMustBeNamed) { + if let TirTypeError::RuntimeTypeMustBeNamed { escape } = error { // The headline says what is wrong; the label at the `unreflect(...)` - // slot says why the inline spelling cannot reach past this call. The - // rewrite rides along as related info, built from the file text in - // `render_with_type_refs`. + // slot says why the inline spelling cannot reach past this call — + // naming whichever published type the runtime parameter reached, the + // value or the error. The rewrite rides along as related info, built + // from the file text in `render_with_type_refs`. return runtime_type::runtime_type_must_be_named() - .with_primary(span, runtime_type::RUNTIME_TYPE_MUST_BE_NAMED_NOTE) + .with_primary(span, escape.note()) .with_phase(DiagnosticPhase::Type); } if let TirTypeError::CannotConstructReflectionKind { class_name } = error { @@ -1485,7 +1486,9 @@ fn tir_type_error_to_diagnostic_id( TirTypeError::ComputedGenericArgumentRequiresUnreflect { name } => { runtime_type::computed_generic_argument_requires_unreflect(name.as_str()).id } - TirTypeError::RuntimeTypeMustBeNamed => runtime_type::runtime_type_must_be_named().id, + TirTypeError::RuntimeTypeMustBeNamed { .. } => { + runtime_type::runtime_type_must_be_named().id + } TirTypeError::MountedPackageCallUnsupported { path } => { runtime_type::mounted_package_call_unsupported(path.as_str()).id } diff --git a/baml_language/crates/baml_tests/tests/runtime_type_escape.rs b/baml_language/crates/baml_tests/tests/runtime_type_escape.rs index 3d499d6001f..ae1b7218591 100644 --- a/baml_language/crates/baml_tests/tests/runtime_type_escape.rs +++ b/baml_language/crates/baml_tests/tests/runtime_type_escape.rs @@ -2,13 +2,20 @@ //! only while the runtime type stays out of the expression's published type. //! //! The parameter an inline `unreflect(...)` introduces is rigid for exactly -//! one call. A result that IS that parameter (`parse(..) -> T`) is fine — +//! one call. A type that IS that parameter (`parse(..) -> T`) is fine — //! the value carries its own runtime tag and nothing static claims more than -//! `unknown`. A result that EMBEDS it (`Wrapper`, `T[]`, a constructed +//! `unknown`. A type that EMBEDS it (`Wrapper`, `T[]`, a constructed //! `Agent`) is not: the published type would keep naming a parameter that //! no longer exists, and every later dispatch re-derives its arguments from //! that type. Those spellings are refused with E0168, which suggests the //! lexical `type Out = unreflect(v)` binding that does outlive a call. +//! +//! A call publishes three things, and the rule reads the same in all three: +//! the RESULT it returns, the ERROR its `throws` clause hands the caller — +//! written or inferred — and, when a `?.` chain short-circuits it, the +//! `| null` the chain wraps around the result. The last section sweeps every +//! other construct that transports a call's result and pins where it lands, +//! because the bar is that no spelling reaches a runtime failure. use baml_compiler_diagnostics::Severity; use baml_project::{collect_diagnostics, testing::setup_test_db}; @@ -47,6 +54,17 @@ fn assert_accepted(source: &str) { assert!(errors.is_empty(), "expected no errors, got: {errors:#?}"); } +/// The exact codes a source reports — for the audited shapes whose refusal is +/// some other rule's to make, so a later change that hands them to E0168 (or +/// to nothing at all) has to come through here. +fn assert_error_codes(source: &str, expected: &[&str]) { + let codes: Vec = compile_errors(source) + .into_iter() + .map(|(code, _)| code) + .collect(); + assert_eq!(codes, expected, "for:\n{source}"); +} + /// Render one file's diagnostics the way `baml check` shows them, so the /// snapshot below pins the headline, the note at the `unreflect(...)` slot and /// the suggested rewrite together — the three parts users read. @@ -276,6 +294,210 @@ function main(t: type) -> unknown { ); } +// ── Refused: the error the call can throw would keep naming the parameter ── + +/// A `throws` clause publishes a type exactly like a result does — the +/// caller's handler binds the error after the call has returned — so a clause +/// that still names the call-scoped parameter is the same lie, and gets the +/// same report with the note worded for the error channel. +#[test] +fn a_declared_throws_that_wraps_the_parameter_is_refused() { + assert_single_escape( + r#" +class Boom { payload T } + +function risky(v: T) -> int throws Boom { throw Boom { payload: v } } + +function main(t: type) -> unknown throws unknown { risky(1) } +"#, + ); +} + +/// The wrinkle that deferred this half of the rule: a `throws` clause is +/// INFERRED from the body when the author writes none, so the report fires on +/// a callee with no `throws` in sight. Nothing about the refusal changes — the +/// error is just as published either way — and the note is worded for exactly +/// this reading: "the error this call can throw", never "the clause you +/// wrote". +#[test] +fn an_inferred_throws_that_wraps_the_parameter_is_refused() { + assert_single_escape( + r#" +class Boom { payload T } + +function risky(v: T, fail: bool) -> int { + if (fail) { throw Boom { payload: v } } + 0 +} + +function main(t: type) -> unknown throws unknown { risky(1, true) } +"#, + ); +} + +/// The inferred case, rendered: the report has to read sensibly against a +/// signature whose author never wrote a `throws` clause at all. +#[test] +fn the_report_for_an_inferred_throws_names_the_error_channel() { + insta::assert_snapshot!( + render_errors( + r#" +class Boom { payload T } + +function risky(v: T, fail: bool) -> int { + if (fail) { throw Boom { payload: v } } + 0 +} + +function main(t: type) -> unknown throws unknown { risky(1, true) } +"# + ), + @" + E0168 + + × this runtime type must be given a name before it can be used here + ╭─[test.baml:9:58] + 9 │ function main(t: type) -> unknown throws unknown { risky(1, true) } + · ──────┬───── + · ╰── a type created at runtime only lasts for one call when written inline with `unreflect(...)`, but the error this call can throw would still need it afterwards + ╰──── + ╰─▶ ☞ name the type first, then use the name: + │ type Out = unreflect(t); + │ risky(1, true) + ╭─[test.baml:9:58] + 9 │ function main(t: type) -> unknown throws unknown { risky(1, true) } + · ──────────── + ╰──── + " + ); +} + +/// Catching the error does not make the type legal: the clause is published by +/// the callee, and the handler is one more place that reads it. +#[test] +fn a_throws_that_wraps_the_parameter_is_refused_even_when_caught() { + assert_single_escape( + r#" +class Boom { payload T } + +function risky(v: T) -> int throws Boom { throw Boom { payload: v } } + +function main(t: type) -> unknown throws unknown { + risky(1) catch (e) { _ => 0 } +} +"#, + ); +} + +// ── Refused: a `?.` chain republishes the result as nullable ─────────────── + +/// The asymmetry #4518 left open: a declared `-> T?` was refused while the +/// `?.` spelling of the same published `unknown?` was accepted. Both are the +/// parameter under a constructor now — the chain's `| null` is a wrapper like +/// any other. +#[test] +fn an_optional_chained_call_is_refused() { + assert_single_escape( + r#" +class Source { + function pick(self, v: T) -> T throws never { v } +} + +function main(t: type, s: Source?) -> unknown throws unknown { s?.pick(1) } +"#, + ); +} + +/// The `?.` link does not have to sit on the call itself: any short-circuiting +/// link earlier in the chain publishes the tail's result as nullable, and the +/// tail is the call. +#[test] +fn an_earlier_optional_link_refuses_the_call_at_the_end_of_the_chain() { + assert_single_escape( + r#" +class Source { + function again(self) -> Source throws never { self } + + function pick(self, v: T) -> T throws never { v } +} + +function main(t: type, s: Source?) -> unknown throws unknown { + s?.again().pick(1) +} +"#, + ); +} + +/// `?.[ ]` short-circuits the same way `?.` does, and the tail is still the +/// call. +#[test] +fn an_optional_index_link_refuses_the_call_at_the_end_of_the_chain() { + assert_single_escape( + r#" +class Source { + function pick(self, v: T) -> T throws never { v } +} + +function main(t: type, xs: Source[]?) -> unknown throws unknown { + xs?.[0].pick(1) +} +"#, + ); +} + +/// A slot can escape through more than one published type at once — here the +/// result wraps the parameter AND the chain republishes it as nullable. One +/// slot, one report: the user has one thing to fix. +#[test] +fn a_slot_that_escapes_twice_is_reported_once() { + assert_single_escape( + r#" +class Wrapper { inner T } + +class Source { + function wrapit(self, v: T) -> Wrapper throws never { Wrapper { inner: v } } +} + +function main(t: type, s: Source?) -> unknown throws unknown { s?.wrapit(1) } +"#, + ); +} + +/// The whole `?.` report, as `baml check` prints it. The rewrite quotes the +/// chain back with the slot named, so the suggestion is the line the user +/// already wrote. +#[test] +fn the_report_for_an_optional_chain_spells_the_fix_in_the_chains_own_words() { + insta::assert_snapshot!( + render_errors( + r#" +class Source { + function pick(self, v: T) -> T throws never { v } +} + +function main(t: type, s: Source?) -> unknown throws unknown { s?.pick(1) } +"# + ), + @" + E0168 + + × this runtime type must be given a name before it can be used here + ╭─[test.baml:6:72] + 6 │ function main(t: type, s: Source?) -> unknown throws unknown { s?.pick(1) } + · ──────┬───── + · ╰── a type created at runtime only lasts for one call when written inline with `unreflect(...)`, but the value this expression creates would still need it afterwards + ╰──── + ╰─▶ ☞ name the type first, then use the name: + │ type Out = unreflect(t); + │ s?.pick(1) + ╭─[test.baml:6:72] + 6 │ function main(t: type, s: Source?) -> unknown throws unknown { s?.pick(1) } + · ──────────── + ╰──── + " + ); +} + // ── Accepted: occurrence-typed values, declared erasure, the lexical form ─── /// `parse(..) -> T` is the shape the whole dynamic path is built on: the @@ -358,6 +580,232 @@ function main(t: type) -> unknown throws unknown { ); } +/// The error channel keeps the same carve-out the result has: an error typed +/// by the bare parameter is an occurrence-typed VALUE, its runtime tag rides +/// on the thrown value, and the handler sees `unknown` — nothing static claims +/// more. +#[test] +fn a_throws_that_is_the_parameter_stays_legal() { + assert_accepted( + r#" +function fail(v: T) -> int throws T { throw v } + +function main(t: type) -> unknown throws unknown { fail(1) } +"#, + ); +} + +/// A clause that never mentions the parameter is the ordinary case, and the +/// overwhelmingly common one: `throws never`, a fixed error class, a string. +#[test] +fn a_throws_that_never_mentions_the_parameter_stays_legal() { + assert_accepted( + r#" +class Boom { note string } + +function risky(v: T, fail: bool) -> T throws Boom { + if (fail) { throw Boom { note: "no" } } + v +} + +function main(t: type) -> unknown throws unknown { risky(1, false) } +"#, + ); +} + +/// A `?.` link that short-circuits nothing publishes no wrapper: the receiver +/// is not nullable, the chain adds no `| null`, and the call's result is the +/// bare parameter it always was. The rule reads the published type, not the +/// punctuation. +#[test] +fn an_inert_optional_link_publishes_no_wrapper_and_stays_legal() { + assert_accepted( + r#" +class Source { + function pick(self, v: T) -> T throws never { v } +} + +function main(t: type, s: Source) -> unknown throws unknown { s?.pick(1) } +"#, + ); +} + +/// Only the chain's TAIL is republished as nullable. A call in argument +/// position inside a chain hands its value straight to the parameter it fills, +/// so it keeps the verdict its own signature earned. +#[test] +fn a_call_in_argument_position_inside_a_chain_stays_legal() { + assert_accepted( + r#" +class Source { + function keep(self, v: unknown) -> string throws never { "kept" } +} + +function ident(v: T) -> T throws never { v } + +function main(t: type, s: Source?) -> unknown throws unknown { + s?.keep(ident(1)) +} +"#, + ); +} + +// ── Audit: every other construct that transports a call's result ─────────── +// +// The parameter an inline `unreflect(...)` introduces can only reach a +// published type through the callee's own signature (result and `throws`, +// both checked above), through a class literal's `C<…T…>` (checked at +// lowering), or through a wrapper the compiler itself adds — the `?.` chain's +// `| null`. Everything else downstream consumes the SUBSTITUTED type, which is +// `unknown` (or the parameter's first bound), and can no longer name the +// parameter at all. These pin that reading for the transports that looked like +// candidates. + +/// `spawn` wraps the block's value in a `Future` — but `V` is the +/// already-erased `unknown`, so the future publishes nothing about the +/// parameter, and awaiting hands the value back with its runtime tag intact. +#[test] +fn spawning_and_awaiting_the_result_stays_legal() { + assert_accepted( + r#" +function ident(v: T) -> T throws never { v } + +function main(t: type) -> unknown throws unknown { + let running = spawn { ident(1) } + await running +} +"#, + ); +} + +/// …and a result that escapes is refused inside a `spawn` exactly as it is +/// outside one: the report is made where the call is written. +#[test] +fn spawning_an_escaping_call_is_still_refused() { + assert_single_escape( + r#" +class Wrapper { inner T } + +function wrap(v: T) -> Wrapper throws never { Wrapper { inner: v } } + +function main(t: type) -> unknown throws unknown { + let running = spawn { wrap(1) } + await running +} +"#, + ); +} + +/// A `catch` joins the call's published type with its handlers'. The call +/// published `unknown`, so the join does too. +#[test] +fn catching_the_result_stays_legal() { + assert_accepted( + r#" +function ident(v: T) -> T throws string { v } + +function main(t: type) -> unknown throws unknown { + ident(1) catch (e) { _ => 0 } +} +"#, + ); +} + +/// A `match` arm join is the same story: the arms contribute their published +/// types, and this call's is the erased one. +#[test] +fn joining_the_result_in_match_arms_stays_legal() { + assert_accepted( + r#" +function ident(v: T) -> T throws never { v } + +function main(t: type, flag: bool) -> unknown throws unknown { + match flag { + true => ident(1), + false => 0, + } +} +"#, + ); +} + +/// A streaming call publishes `T` as a stream of partials, so the result rule +/// already refuses it — on top of the older guard that says a streaming call +/// carries no runtime type arguments at all. Both refusals are the same +/// answer; the shape is pinned so it cannot quietly become neither. +#[test] +fn a_streaming_call_with_an_inline_runtime_type_is_refused_twice() { + assert_error_codes( + &format!( + r##" + {PROBE_CLIENT} + + function Extract(document: string) -> T {{ + client: DefaultClient + prompt: `${{document}} ${{ctx.output_format}}` + }} + + function main(t: type, document: string) -> unknown throws unknown {{ + Extract$stream(document) + }} + "## + ), + &["E0010", "E0168"], + ); +} + +/// There is no postfix `!` to transport anything through: BAML has no non-null +/// assertion operator, and the parser says so by name. Pinned here because the +/// audit's answer for that shape is "the spelling does not exist", and this is +/// what makes that answer checkable. +#[test] +fn a_postfix_bang_is_not_a_spelling_this_rule_has_to_cover() { + assert_error_codes( + r#" +function ident(v: T) -> T throws never { v } + +function main(t: type) -> unknown throws unknown { ident(1)! } +"#, + &["E0010"], + ); +} + +/// A spread cannot transport the result at all: it demands a known class +/// shape, and the erased `unknown` is not one. Nothing to publish, nothing to +/// escape — the refusal is the ordinary type error, made long before this +/// rule. +#[test] +fn a_spread_of_the_result_is_refused_by_its_own_rule() { + assert_error_codes( + r#" +class Point { x int, y int } + +function ident(v: T) -> T throws never { v } + +function main(t: type) -> unknown throws unknown { + Point { ...ident(Point { x: 1, y: 2 }), x: 3 } +} +"#, + &["E0001"], + ); +} + +/// A container literal built from the result is typed from the erased element, +/// never from the parameter. +#[test] +fn collecting_the_result_into_a_literal_stays_legal() { + assert_accepted( + r#" +function ident(v: T) -> T throws never { v } + +function main(t: type) -> unknown throws unknown { + let collected = [ident(1), ident(2)] + collected +} +"#, + ); +} + /// `unreflect` is contextual syntax in one exact position. It stays an /// ordinary identifier everywhere else — as a user's own class name, as a /// local binding, and as a function called with the very `unreflect(` shape @@ -432,3 +880,31 @@ async fn applying_the_suggestion_compiles_and_runs() { let output = baml_test!(&applied); assert_eq!(output.result, Ok(BexExternalValue::String("Pixel".into()))); } + +/// The bar the audit is held to: a shape that compiles must also RUN. Every +/// accepted transport above, on one runtime type, asking each value what class +/// it actually carries — the tag rides on the value, so all four agree, and +/// none of them reaches a runtime failure. +#[tokio::test] +async fn the_accepted_transports_run_and_keep_the_runtime_type() { + let program = r#" +function ident(v: T) -> T throws string { v } + +function main() -> string throws unknown { + let t = type.of() + let direct = ident("a") + let awaited = await spawn { ident("b") } + let caught = ident("c") catch (e) { _ => 0 } + let collected = [ident("d")] + `${type.of_value(direct).to_string()} ${type.of_value(awaited).to_string()} ${type.of_value(caught).to_string()} ${type.of_value(collected[0]).to_string()}` +} +"#; + assert_accepted(program); + let output = baml_test!(program); + assert_eq!( + output.result, + Ok(BexExternalValue::String( + "string string string string".into() + )) + ); +} From 919d653e7151a90ca6e5b00999c6942a8ebaa23d Mon Sep 17 00:00:00 2001 From: Antonio Sarosi Date: Wed, 19 Aug 2026 17:17:47 +0200 Subject: [PATCH 2/4] Add the changelog entry for the extended escape rule --- baml_language/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/baml_language/CHANGELOG.md b/baml_language/CHANGELOG.md index 0031126629a..0ed569d26c0 100644 --- a/baml_language/CHANGELOG.md +++ b/baml_language/CHANGELOG.md @@ -31,6 +31,7 @@ This changelog covers the independent `baml_language` release line. It does not ### Fixes +- Extended the inline `unreflect(value)` escape rule (E0168) to the two published types it did not read: the error a callee can throw — whether the `throws` clause was written or inferred — and an optional-chained call, where `?.` republishes the result as a nullable. `risky(v)` on a callee that can throw `Boom`, and `x?.m()` on a nullable receiver, are refused with the same suggestion to name the type first (`type Out = unreflect(value)`), and a slot that escapes through more than one published type is reported once. An error typed by the parameter itself (`throws T`), a call in argument position inside a chain, and a `?.` link whose receiver is not nullable are unaffected, as is every shape #4518 accepted. ([#4530](https://github.com/BoundaryML/baml/pull/4530)) - Antonio Sarosi - Made an assignment inside a `reflect.Session` type-check against its binding, the way ordinary code does: `s.eval("let n = 5")` followed by `s.eval("n = \"seven\"")` is now the same compile error BAML gives anywhere else, instead of rebinding at another type and failing later in the VM with an uncatchable internal error. Compound assignments are checked through the ordinary road too. Re-declaring with `let` at a new type stays legal — it is a new binding, exactly as shadowing works in ordinary code — and assignments at the binding's own type are unchanged. One previously accidental case changes with it: `n += 1.5` on an `int` binding used to commit the float and read back `6.5`, and now compiles and fails at runtime exactly as the same line does in ordinary BAML. ([#4531](https://github.com/BoundaryML/baml/pull/4531)) - Antonio Sarosi - Made a `let` binding that lives in a global — a Session's top-level bindings and every `client` declaration — behave like an ordinary binding. A method call on one now reaches its receiver instead of failing with an internal `got any` error: `MyClient.id()` in ordinary BAML, and `n.abs()`, `text.to_upper_case()`, `v.length()`, `m.keys()`, a session class's own method and a reflection handle's `fields()` inside a Session. Field access and indexing on such a binding are unchanged. Separately, a Session's `let` now widens a fresh literal initializer to its base type, so `s.eval("let n = 5")` binds `int` rather than the literal type `5`. Two consequences of that are deliberate and visible: a match covering only the literal is now non-exhaustive (and a complete `true`/`false` match on a bool binding is now legal), and an eval contract naming the literal type (`s.eval<5>("n")`) no longer accepts the widened binding. Interface-dispatched methods on such a binding (`n.compare(m)`) reach a separate pre-existing defect and fail with an internal error where they previously failed to compile. ([#4529](https://github.com/BoundaryML/baml/pull/4529)) - Antonio Sarosi - Carried a constructed type's identity through interface dispatch: `type.of()` inside an implements-block or inherited default method is now the exact `type` value the caller minted for a `reflect.class.new` or `reflect.enum.new` declaration, instead of an `==`-distinct re-derivation of the same type. Identity-keyed patterns — a registry keyed by type, a comparison against a stored type — match inside an impl body instead of silently missing. Statically instantiated type parameters, derived types (`t.array()`, `type.meta(…)`), and declarations from a compiled `reflect.Package` keep re-deriving their value, so they are unaffected. ([#4516](https://github.com/BoundaryML/baml/pull/4516)) - Antonio Sarosi From ae4a9d2e257987aa711637e46269ba9d3861d138 Mon Sep 17 00:00:00 2001 From: Antonio Sarosi Date: Wed, 19 Aug 2026 19:41:24 +0200 Subject: [PATCH 3/4] Erase a block's runtime type from its effect channel and narrow the chain check --- baml_language/CHANGELOG.md | 2 + .../crates/baml_compiler2_hir_ty/src/infer.rs | 94 +++++++++ .../baml_tests/tests/runtime_type_escape.rs | 198 +++++++++++++++--- 3 files changed, 261 insertions(+), 33 deletions(-) diff --git a/baml_language/CHANGELOG.md b/baml_language/CHANGELOG.md index 0ed569d26c0..05cc131c1b0 100644 --- a/baml_language/CHANGELOG.md +++ b/baml_language/CHANGELOG.md @@ -31,6 +31,8 @@ This changelog covers the independent `baml_language` release line. It does not ### Fixes +- Extended the inline `unreflect(value)` escape rule (E0168) to the two published types it did not read: the error a callee can throw — whether the `throws` clause was written or inferred — and an optional-chained call, where `?.` republishes the call's result as a nullable. `risky(v)` on a callee that can throw `Boom`, and `x?.m()` on a nullable receiver whose callee returns `T`, are refused with the same suggestion to name the type first (`type Out = unreflect(value)`), and a slot that escapes through more than one published type is reported once. Both arms read the published type, so an error typed by the parameter itself (`throws T`), a chained call whose result never mentions it (`-> bool`, `-> Wrapper`), a call in argument position inside a chain, and a `?.` link whose receiver is not nullable are all unaffected, as is every shape #4518 accepted. ([#4530](https://github.com/BoundaryML/baml/pull/4530)) - Antonio Sarosi +- Fixed a compiler abort (`type variable not found in type args`) on the rewrite E0168 suggests: a `type Out = unreflect(value)` binding used on a callee that can throw a type mentioning its parameter left that block-scoped name in the caller's inferred `throws`, which reached lowering with nothing to bind it to. The effect a block publishes is now erased like its value, so such a caller compiles and publishes `Boom`, and E0096 quotes that instead of the internal name. ([#4530](https://github.com/BoundaryML/baml/pull/4530)) - Antonio Sarosi - Extended the inline `unreflect(value)` escape rule (E0168) to the two published types it did not read: the error a callee can throw — whether the `throws` clause was written or inferred — and an optional-chained call, where `?.` republishes the result as a nullable. `risky(v)` on a callee that can throw `Boom`, and `x?.m()` on a nullable receiver, are refused with the same suggestion to name the type first (`type Out = unreflect(value)`), and a slot that escapes through more than one published type is reported once. An error typed by the parameter itself (`throws T`), a call in argument position inside a chain, and a `?.` link whose receiver is not nullable are unaffected, as is every shape #4518 accepted. ([#4530](https://github.com/BoundaryML/baml/pull/4530)) - Antonio Sarosi - Made an assignment inside a `reflect.Session` type-check against its binding, the way ordinary code does: `s.eval("let n = 5")` followed by `s.eval("n = \"seven\"")` is now the same compile error BAML gives anywhere else, instead of rebinding at another type and failing later in the VM with an uncatchable internal error. Compound assignments are checked through the ordinary road too. Re-declaring with `let` at a new type stays legal — it is a new binding, exactly as shadowing works in ordinary code — and assignments at the binding's own type are unchanged. One previously accidental case changes with it: `n += 1.5` on an `int` binding used to commit the float and read back `6.5`, and now compiles and fails at runtime exactly as the same line does in ordinary BAML. ([#4531](https://github.com/BoundaryML/baml/pull/4531)) - Antonio Sarosi - Made a `let` binding that lives in a global — a Session's top-level bindings and every `client` declaration — behave like an ordinary binding. A method call on one now reaches its receiver instead of failing with an internal `got any` error: `MyClient.id()` in ordinary BAML, and `n.abs()`, `text.to_upper_case()`, `v.length()`, `m.keys()`, a session class's own method and a reflection handle's `fields()` inside a Session. Field access and indexing on such a binding are unchanged. Separately, a Session's `let` now widens a fresh literal initializer to its base type, so `s.eval("let n = 5")` binds `int` rather than the literal type `5`. Two consequences of that are deliberate and visible: a match covering only the literal is now non-exhaustive (and a complete `true`/`false` match on a bool binding is now legal), and an eval contract naming the literal type (`s.eval<5>("n")`) no longer accepts the widened binding. Interface-dispatched methods on such a binding (`n.compare(m)`) reach a separate pre-existing defect and fail with an internal error where they previously failed to compile. ([#4529](https://github.com/BoundaryML/baml/pull/4529)) - Antonio Sarosi diff --git a/baml_language/crates/baml_compiler2_hir_ty/src/infer.rs b/baml_language/crates/baml_compiler2_hir_ty/src/infer.rs index eae92c6636d..d89de4573e0 100644 --- a/baml_language/crates/baml_compiler2_hir_ty/src/infer.rs +++ b/baml_language/crates/baml_compiler2_hir_ty/src/infer.rs @@ -1660,6 +1660,10 @@ struct InferenceContext<'db> { /// Carriers already reported as escaping their call (E0168), so a callee /// road walked twice reports once. reported_runtime_escapes: rustc_hash::FxHashSet, + /// Inline `unreflect(...)` carriers whose call publishes the parameter in + /// its RESULT — the bare `-> T` included. The `?.` check consults this at + /// the chain boundary, where the callee's signature is no longer reachable. + runtime_slots_named_by_result: rustc_hash::FxHashSet, result: InferenceResult<'db>, } @@ -1721,6 +1725,7 @@ impl<'db> InferenceContext<'db> { wf_scope_env: std::cell::OnceCell::new(), runtime_dependent_call_params: FxHashMap::default(), reported_runtime_escapes: rustc_hash::FxHashSet::default(), + runtime_slots_named_by_result: rustc_hash::FxHashSet::default(), result: InferenceResult::default(), } } @@ -2813,6 +2818,29 @@ impl<'db> InferenceContext<'db> { for ty in self.flow.values_mut() { *ty = replace_rigid_param(ty, &binding.parameter, &binding.occurrence_ty); } + // The effect channel leaves the block exactly as the value does. + // An undeclared `throws` is assembled from these contributions at + // finalize, so a rigid parameter left in one becomes part of the + // OWNER's published effect — a type naming a parameter that stops + // existing at the closing brace, which reaches lowering with no + // type argument to bind it to. + for channel in &mut self.throws_channels { + for (_, contribution) in channel.iter_mut() { + *contribution = replace_rigid_param( + contribution, + &binding.parameter, + &binding.occurrence_ty, + ); + } + } + // A contract violation stashed inside the block quotes the effect + // it saw. Erase there too, or E0096 prints a name (`Boom`) + // that means nothing outside the block it was read in. + for pending in &mut self.pending_diags { + if let PendingDiag::ThrowsViolation { extra, .. } = pending { + *extra = replace_rigid_param(extra, &binding.parameter, &binding.occurrence_ty); + } + } } self.scoped_type_bindings.truncate(checkpoint); block_ty @@ -7299,6 +7327,17 @@ impl<'db> InferenceContext<'db> { published: &Ty, escape: RuntimeTypeEscape, ) { + if escape == RuntimeTypeEscape::Value { + // The `?.` check runs later, at the chain boundary, where the + // callee's signature is long out of reach — so the one fact it + // needs is recorded here: does this call's RESULT name the + // parameter at all? A result that never mentions it (`-> bool`, + // `-> Wrapper`) publishes nothing about the runtime type, + // and wrapping nothing in `| null` is still nothing. + self.runtime_slots_named_by_result.extend( + self.escaping_carriers(call, |parameter| ty_mentions_param(published, parameter)), + ); + } let escaping = self.escaping_carriers(call, |parameter| { runtime_param_escapes(published, parameter) }); @@ -7354,6 +7393,13 @@ impl<'db> InferenceContext<'db> { /// spelling half of the declared `-> T?` refusal — both publish `unknown?` /// and both now say the same thing. /// + /// It is still the published type that decides, never the punctuation: a + /// tail whose result never mentions the parameter (`-> bool`, or the + /// declared-erased `-> Wrapper`) publishes nothing about the + /// runtime type, so the chain's `| null` has nothing to wrap and the call + /// stays legal. [`Self::report_runtime_type_escape`] recorded that fact + /// while the callee's signature was still in hand. + /// /// Only the chain's TAIL is affected: it is the expression whose value the /// chain republishes. A call in argument position, or one whose result is /// consumed further along the chain (`a?.b.m().field`), @@ -7367,6 +7413,10 @@ impl<'db> InferenceContext<'db> { return; } let escaping = self.escaping_carriers(tail, |_| true); + let escaping: Vec = escaping + .into_iter() + .filter(|carrier| self.runtime_slots_named_by_result.contains(carrier)) + .collect(); self.report_escaping_carriers(tail, escaping, RuntimeTypeEscape::Value); } @@ -11356,3 +11406,47 @@ mod syntactic_union_tests { assert_eq!(flat, union(&[Ty::int(), Ty::string()])); } } + +#[cfg(test)] +mod runtime_param_escape_tests { + use super::*; + + fn param() -> baml_type::ParamTy { + baml_type::ParamTy::new(0x8000_0001, baml_type::Name::new("Out")) + } + + fn var(param: &baml_type::ParamTy) -> Ty { + Ty::intern(TyKind::TypeVar(param.clone(), TyAttr::default())) + } + + /// The whole rule is one boundary: the parameter ITSELF is a value's type + /// and does not escape; one constructor deeper it is an assertion about a + /// value, and does. + #[test] + fn the_bare_parameter_is_the_only_shape_that_does_not_escape() { + let param = param(); + assert!(!runtime_param_escapes(&var(¶m), ¶m)); + assert!(!runtime_param_escapes(&Ty::int(), ¶m)); + assert!(!runtime_param_escapes(&Ty::never(), ¶m)); + assert!(runtime_param_escapes( + &Ty::intern(TyKind::List(var(¶m), TyAttr::default())), + ¶m + )); + assert!(runtime_param_escapes( + &syntactic_union(&[var(¶m), Ty::null()]), + ¶m + )); + } + + /// A different parameter is a different name: an occurs-check that keyed on + /// shape alone would refuse every generic result. + #[test] + fn another_parameter_is_not_this_one() { + let other = baml_type::ParamTy::new(0x8000_0002, baml_type::Name::new("Other")); + assert!(!runtime_param_escapes(&var(&other), ¶m())); + assert!(!runtime_param_escapes( + &Ty::intern(TyKind::List(var(&other), TyAttr::default())), + ¶m() + )); + } +} diff --git a/baml_language/crates/baml_tests/tests/runtime_type_escape.rs b/baml_language/crates/baml_tests/tests/runtime_type_escape.rs index ae1b7218591..49cf0d054ec 100644 --- a/baml_language/crates/baml_tests/tests/runtime_type_escape.rs +++ b/baml_language/crates/baml_tests/tests/runtime_type_escape.rs @@ -13,9 +13,12 @@ //! A call publishes three things, and the rule reads the same in all three: //! the RESULT it returns, the ERROR its `throws` clause hands the caller — //! written or inferred — and, when a `?.` chain short-circuits it, the -//! `| null` the chain wraps around the result. The last section sweeps every -//! other construct that transports a call's result and pins where it lands, -//! because the bar is that no spelling reaches a runtime failure. +//! `| null` the chain wraps around the result. All three read the published +//! TYPE, never the spelling: a chained call whose result never mentions the +//! parameter (`-> bool`, `-> Wrapper`) has nothing for the `| null` +//! to wrap and stays legal. The last section sweeps every other construct that +//! transports a call's result and pins where it lands, because the bar is that +//! no spelling reaches a runtime failure. use baml_compiler_diagnostics::Severity; use baml_project::{collect_diagnostics, testing::setup_test_db}; @@ -308,7 +311,7 @@ class Boom { payload T } function risky(v: T) -> int throws Boom { throw Boom { payload: v } } -function main(t: type) -> unknown throws unknown { risky(1) } +function main(t: type) -> unknown { risky(1) } "#, ); } @@ -330,7 +333,7 @@ function risky(v: T, fail: bool) -> int { 0 } -function main(t: type) -> unknown throws unknown { risky(1, true) } +function main(t: type) -> unknown { risky(1, true) } "#, ); } @@ -349,24 +352,24 @@ function risky(v: T, fail: bool) -> int { 0 } -function main(t: type) -> unknown throws unknown { risky(1, true) } +function main(t: type) -> unknown { risky(1, true) } "# ), @" E0168 × this runtime type must be given a name before it can be used here - ╭─[test.baml:9:58] - 9 │ function main(t: type) -> unknown throws unknown { risky(1, true) } - · ──────┬───── - · ╰── a type created at runtime only lasts for one call when written inline with `unreflect(...)`, but the error this call can throw would still need it afterwards + ╭─[test.baml:9:43] + 9 │ function main(t: type) -> unknown { risky(1, true) } + · ──────┬───── + · ╰── a type created at runtime only lasts for one call when written inline with `unreflect(...)`, but the error this call can throw would still need it afterwards ╰──── ╰─▶ ☞ name the type first, then use the name: │ type Out = unreflect(t); │ risky(1, true) - ╭─[test.baml:9:58] - 9 │ function main(t: type) -> unknown throws unknown { risky(1, true) } - · ──────────── + ╭─[test.baml:9:43] + 9 │ function main(t: type) -> unknown { risky(1, true) } + · ──────────── ╰──── " ); @@ -382,7 +385,7 @@ class Boom { payload T } function risky(v: T) -> int throws Boom { throw Boom { payload: v } } -function main(t: type) -> unknown throws unknown { +function main(t: type) -> unknown { risky(1) catch (e) { _ => 0 } } "#, @@ -403,7 +406,7 @@ class Source { function pick(self, v: T) -> T throws never { v } } -function main(t: type, s: Source?) -> unknown throws unknown { s?.pick(1) } +function main(t: type, s: Source?) -> unknown { s?.pick(1) } "#, ); } @@ -421,7 +424,7 @@ class Source { function pick(self, v: T) -> T throws never { v } } -function main(t: type, s: Source?) -> unknown throws unknown { +function main(t: type, s: Source?) -> unknown { s?.again().pick(1) } "#, @@ -438,7 +441,7 @@ class Source { function pick(self, v: T) -> T throws never { v } } -function main(t: type, xs: Source[]?) -> unknown throws unknown { +function main(t: type, xs: Source[]?) -> unknown { xs?.[0].pick(1) } "#, @@ -458,7 +461,7 @@ class Source { function wrapit(self, v: T) -> Wrapper throws never { Wrapper { inner: v } } } -function main(t: type, s: Source?) -> unknown throws unknown { s?.wrapit(1) } +function main(t: type, s: Source?) -> unknown { s?.wrapit(1) } "#, ); } @@ -475,24 +478,24 @@ class Source { function pick(self, v: T) -> T throws never { v } } -function main(t: type, s: Source?) -> unknown throws unknown { s?.pick(1) } +function main(t: type, s: Source?) -> unknown { s?.pick(1) } "# ), @" E0168 × this runtime type must be given a name before it can be used here - ╭─[test.baml:6:72] - 6 │ function main(t: type, s: Source?) -> unknown throws unknown { s?.pick(1) } - · ──────┬───── - · ╰── a type created at runtime only lasts for one call when written inline with `unreflect(...)`, but the value this expression creates would still need it afterwards + ╭─[test.baml:6:57] + 6 │ function main(t: type, s: Source?) -> unknown { s?.pick(1) } + · ──────┬───── + · ╰── a type created at runtime only lasts for one call when written inline with `unreflect(...)`, but the value this expression creates would still need it afterwards ╰──── ╰─▶ ☞ name the type first, then use the name: │ type Out = unreflect(t); │ s?.pick(1) - ╭─[test.baml:6:72] - 6 │ function main(t: type, s: Source?) -> unknown throws unknown { s?.pick(1) } - · ──────────── + ╭─[test.baml:6:57] + 6 │ function main(t: type, s: Source?) -> unknown { s?.pick(1) } + · ──────────── ╰──── " ); @@ -590,7 +593,7 @@ fn a_throws_that_is_the_parameter_stays_legal() { r#" function fail(v: T) -> int throws T { throw v } -function main(t: type) -> unknown throws unknown { fail(1) } +function main(t: type) -> unknown { fail(1) } "#, ); } @@ -608,7 +611,7 @@ function risky(v: T, fail: bool) -> T throws Boom { v } -function main(t: type) -> unknown throws unknown { risky(1, false) } +function main(t: type) -> unknown { risky(1, false) } "#, ); } @@ -625,7 +628,45 @@ class Source { function pick(self, v: T) -> T throws never { v } } -function main(t: type, s: Source) -> unknown throws unknown { s?.pick(1) } +function main(t: type, s: Source) -> unknown { s?.pick(1) } +"#, + ); +} + +/// The chain rule reads the published type too, not the punctuation. A tail +/// whose result never mentions the parameter publishes nothing about the +/// runtime type, so there is nothing for the chain's `| null` to wrap — and no +/// honest note to write, since the value this expression creates does not need +/// the type afterwards. +#[test] +fn a_chain_tail_whose_result_never_mentions_the_parameter_stays_legal() { + assert_accepted( + r#" +class Source { + function put(self, v: T) -> bool throws never { true } +} + +function main(t: type, s: Source?) -> unknown { s?.put(1) } +"#, + ); +} + +/// The `?.` spelling of #4518's declared-erasure row: `-> Wrapper` is +/// the author's own contract that nothing about `T` is published, and a chain +/// around it does not change that. Accepted straight, accepted chained. +#[test] +fn a_chain_tail_with_a_declared_erased_result_stays_legal() { + assert_accepted( + r#" +class Wrapper { inner T } + +class Source { + function erase(self, v: T) -> Wrapper throws never { + Wrapper { inner: v } + } +} + +function main(t: type, s: Source?) -> unknown { s?.erase(1) } "#, ); } @@ -643,7 +684,7 @@ class Source { function ident(v: T) -> T throws never { v } -function main(t: type, s: Source?) -> unknown throws unknown { +function main(t: type, s: Source?) -> unknown { s?.keep(ident(1)) } "#, @@ -790,6 +831,38 @@ function main(t: type) -> unknown throws unknown { ); } +/// An array spread has no spelling in the grammar at all, so there is no road +/// for a result to travel that way. Pinned because the audit's answer is again +/// "this shape does not exist", and a grammar that grew one should come back +/// through this rule. +#[test] +fn an_array_spread_is_not_a_spelling_this_rule_has_to_cover() { + assert_error_codes( + r#" +function listed(v: T) -> T[] throws never { [v] } + +function main(t: type) -> unknown { [...listed(1)] } +"#, + &["E0010"], + ); +} + +/// An annotation cannot name a call-scoped parameter — there is no spelling +/// for it — so a binding's declared type can only ever hold the erased one. +#[test] +fn an_annotated_binding_of_the_result_stays_legal() { + assert_accepted( + r#" +function ident(v: T) -> T throws never { v } + +function main(t: type) -> unknown { + let held: unknown = ident(1) + held +} +"#, + ); +} + /// A container literal built from the result is typed from the erased element, /// never from the parameter. #[test] @@ -881,22 +954,81 @@ async fn applying_the_suggestion_compiles_and_runs() { assert_eq!(output.result, Ok(BexExternalValue::String("Pixel".into()))); } +/// The suggestion has to work for the OTHER escape too, and this is the shape +/// that caught a real defect: with the caller's own `throws` clause omitted, +/// the named rewrite used to reach MIR still carrying the block-scoped +/// parameter in the effect channel and abort with "type variable not found in +/// type args: Out". The inline spelling was refused, so the only road out of +/// E0168 was the one that crashed. +/// +/// Now the block erases its parameter from the effect channel exactly as it +/// does from the value: the program compiles, runs, and publishes +/// `Boom` — which the third case reads back out of the contract +/// violation, the one place that prints the effect a body may throw. +#[tokio::test] +async fn applying_the_suggestion_to_a_throws_escape_compiles_and_runs() { + let scenario = |bind: &str, slot: &str, clause: &str| { + format!( + r#" +class Boom {{ payload T }} + +function risky(v: T, fail: bool) -> int throws Boom {{ + if (fail) {{ throw Boom {{ payload: v }} }} + 0 +}} + +function main() -> int{clause} {{ + let t = type.of() + {bind} + risky<{slot}>("x", false) +}} +"# + ) + }; + + assert_single_escape(&scenario("", "unreflect(t)", "")); + + // Exactly the rewrite E0168 prints, on a caller that declares no clause of + // its own — the effect is inferred, which is what used to leak. + let applied = scenario("type Out = unreflect(t)", "Out", ""); + assert_accepted(&applied); + let output = baml_test!(&applied); + assert_eq!(output.result, Ok(BexExternalValue::Int(0))); + + // The effect the caller publishes, quoted back by the contract check: the + // erased `Boom`, never the block-scoped `Boom`. + assert_eq!( + compile_errors(&scenario("type Out = unreflect(t)", "Out", " throws never")), + vec![( + "E0096".to_string(), + "declared throws is `never`, but this function may also throw `Boom`" + .to_string(), + )], + ); +} + /// The bar the audit is held to: a shape that compiles must also RUN. Every /// accepted transport above, on one runtime type, asking each value what class -/// it actually carries — the tag rides on the value, so all four agree, and +/// it actually carries — the tag rides on the value, so all five agree, and /// none of them reaches a runtime failure. #[tokio::test] async fn the_accepted_transports_run_and_keep_the_runtime_type() { let program = r#" +class Source { + function pick(self, v: T) -> T throws never { v } +} + function ident(v: T) -> T throws string { v } function main() -> string throws unknown { let t = type.of() + let src = Source {} let direct = ident("a") let awaited = await spawn { ident("b") } let caught = ident("c") catch (e) { _ => 0 } let collected = [ident("d")] - `${type.of_value(direct).to_string()} ${type.of_value(awaited).to_string()} ${type.of_value(caught).to_string()} ${type.of_value(collected[0]).to_string()}` + let chained = src?.pick("e") + `${type.of_value(direct).to_string()} ${type.of_value(awaited).to_string()} ${type.of_value(caught).to_string()} ${type.of_value(collected[0]).to_string()} ${type.of_value(chained).to_string()}` } "#; assert_accepted(program); @@ -904,7 +1036,7 @@ function main() -> string throws unknown { assert_eq!( output.result, Ok(BexExternalValue::String( - "string string string string".into() + "string string string string string".into() )) ); } From 405fefb78fd30cbc464c76c1e023809f8126b737 Mon Sep 17 00:00:00 2001 From: Antonio Sarosi Date: Wed, 19 Aug 2026 21:12:29 +0200 Subject: [PATCH 4/4] Pin which throws types a block erases and which it quotes as written --- .../crates/baml_compiler2_hir_ty/src/infer.rs | 13 +++++- .../baml_tests/tests/runtime_type_escape.rs | 43 +++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/baml_language/crates/baml_compiler2_hir_ty/src/infer.rs b/baml_language/crates/baml_compiler2_hir_ty/src/infer.rs index d89de4573e0..898e3e44861 100644 --- a/baml_language/crates/baml_compiler2_hir_ty/src/infer.rs +++ b/baml_language/crates/baml_compiler2_hir_ty/src/infer.rs @@ -2834,8 +2834,17 @@ impl<'db> InferenceContext<'db> { } } // A contract violation stashed inside the block quotes the effect - // it saw. Erase there too, or E0096 prints a name (`Boom`) - // that means nothing outside the block it was read in. + // it saw. `extra` is a COPY of a contribution — compiler-derived, + // and quoted in a report about what the enclosing function may + // throw — so it is erased for the same reason the contribution is. + // + // `declared` deliberately is NOT. It is the clause an author WROTE + // at that site: only a lambda's own clause can name a block-scoped + // binding, its violation is anchored inside that block, and the + // line one row above the caret reads `throws Boom`. Erasing + // it would print `Boom` next to the user's own `Out` — + // `a_lambda_clause_inside_the_block_is_quoted_as_written` pins + // both halves of that asymmetry. for pending in &mut self.pending_diags { if let PendingDiag::ThrowsViolation { extra, .. } = pending { *extra = replace_rigid_param(extra, &binding.parameter, &binding.occurrence_ty); diff --git a/baml_language/crates/baml_tests/tests/runtime_type_escape.rs b/baml_language/crates/baml_tests/tests/runtime_type_escape.rs index 49cf0d054ec..35a4301c0c8 100644 --- a/baml_language/crates/baml_tests/tests/runtime_type_escape.rs +++ b/baml_language/crates/baml_tests/tests/runtime_type_escape.rs @@ -1007,6 +1007,49 @@ function main() -> int{clause} {{ ); } +/// The other half of that erasure, and the reason it stops where it does. A +/// block-scoped name is erased from what LEAVES the block — the effect the +/// enclosing function publishes, and the compiler-derived copy of it a stashed +/// violation quotes — and from nothing else. A clause the author WROTE inside +/// the block is quoted back verbatim, at a caret one row under the line that +/// spells it, where the name is in scope and on screen. +/// +/// So one program reports both spellings, deliberately: the lambda's own +/// `Boom` as written, and the owner's published `Boom`. +#[test] +fn a_lambda_clause_inside_the_block_is_quoted_as_written() { + insta::assert_snapshot!( + render_errors( + r#" +class Boom { payload T } + +function main(t: type) -> unknown throws never { + type Out = unreflect(t) + let f = (v: int) -> int throws Boom { throw "plain" } + f(1) +} +"# + ), + @r#" + E0096 + + × declared throws is `Boom`, but this function may also throw `string` + ╭─[test.baml:6:54] + 6 │ let f = (v: int) -> int throws Boom { throw "plain" } + · ─────── + ╰──── + + E0096 + + × declared throws is `never`, but this function may also throw `Boom` + ╭─[test.baml:7:5] + 7 │ f(1) + · ─ + ╰──── + "# + ); +} + /// The bar the audit is held to: a shape that compiles must also RUN. Every /// accepted transport above, on one runtime type, asking each value what class /// it actually carries — the tag rides on the value, so all five agree, and