From bb73c34c4606a9a5081c6ea1ec6f9a48451446b9 Mon Sep 17 00:00:00 2001 From: xzawed <47019855+xzawed@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:09:25 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(codegen):=20a=20helper=20under=20a=20f?= =?UTF-8?q?allible=20caller=20is=20checked=20too=20=E2=80=94=20the=20call?= =?UTF-8?q?=20path=20decides?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPEC-helper-check-propagation §6 task 1 (DP-P1..P11, user-confirmed 2026-09-26). Arithmetic a fallible body executes is checked wherever it is written. ir::checked_helpers is the one set: infallible functions (exported included, DP-P2) reached from a fallible body through infallible calls whose body or callees can overflow. Codegen gives each member a checked copy `ml_ck_` (DP-P7) and a fallible body calls it, propagating -3 where the call stands (DP-P4); a copy calls its callees' copies, so any depth is covered. The plain body is dropped only when nothing but fallible bodies and copies call it (DP-P8), so a module this slice does not reach emits byte-identical Rust — the generated-Rust golden is unchanged. The same set decides ML_ST_OVERFLOW in both bindings (DP-P6): the prototype returned -3 through a helper while neither binding named it. The Cx context carries the set alongside the return shape instead of global state. DP-O7 is reversed (DP-P10): the checked_arithmetic.rs line that pinned it now asserts -3. Fingerprint and ABI version unchanged (DP-P9); header/unit goldens change only in the comment wording. Docs: LANGUAGE.md, HOST_ABI.md, SPEC status, slice index, STATUS; the reserved.rs header no longer says internal function names are emitted raw (#101). Co-Authored-By: Claude Opus 5.5 (1M context) --- compiler/src/codegen.rs | 263 ++++++++++++------ compiler/src/header.rs | 30 +- compiler/src/ir.rs | 144 ++++++++++ compiler/src/reserved.rs | 7 +- compiler/tests/bindings.rs | 9 + compiler/tests/emit_robustness.rs | 39 +++ compiler/tests/golden/headers.h.txt | 40 +-- compiler/tests/golden/units.pas.txt | 40 +-- compiler/tests/helper_check_propagation.rs | 146 ++++++++++ docs/HOST_ABI.md | 9 +- docs/LANGUAGE.md | 16 +- docs/STATUS.md | 6 +- docs/slices/README.md | 4 +- docs/slices/SPEC-helper-check-propagation.md | 19 +- hosts/rust-oracle/tests/checked_arithmetic.rs | 13 +- .../tests/helper_check_propagation.rs | 238 ++++++++++++++++ 16 files changed, 869 insertions(+), 154 deletions(-) create mode 100644 compiler/tests/helper_check_propagation.rs create mode 100644 hosts/rust-oracle/tests/helper_check_propagation.rs diff --git a/compiler/src/codegen.rs b/compiler/src/codegen.rs index 44e9001..da54d39 100644 --- a/compiler/src/codegen.rs +++ b/compiler/src/codegen.rs @@ -4,6 +4,7 @@ //! --crate-type cdylib`. The IR is backend-independent, so a C-emit backend can be //! added later without touching the front/middle end. +use std::collections::BTreeSet; use std::fmt::Write as _; use std::path::{Path, PathBuf}; use std::process::Command; @@ -184,9 +185,19 @@ pub fn emit(module: &IrModule, module_name: &str) -> Result Result Result<(), CodegenError> { +/// Which functions keep their plain `ml_fn_` body (SPEC-helper-check-propagation DP-P8). +/// +/// Every function does, as before this slice, except a checked helper that no plain body calls: +/// its only callers are fallible bodies and checked copies, and those call `ml_ck_`, so its +/// plain body would be dead code — a warning, and a failed build under an ambient +/// `RUSTFLAGS=-D warnings` (measured on the prototype). A function nobody calls at all is not a +/// checked helper and keeps its body exactly as before (acceptance H). +fn plain_bodies<'a>(module: &'a IrModule, checked: &BTreeSet) -> BTreeSet<&'a str> { + let mut keep: BTreeSet<&str> = module + .functions + .iter() + .filter(|f| f.fallible || f.exported || !checked.contains(&f.name)) + .map(|f| f.name.as_str()) + .collect(); + // A checked helper also keeps its plain body when a kept INFALLIBLE body calls it: that is + // a plain call. A fallible caller never is. Iterated to a fixed point; the graph is a DAG. + loop { + let before = keep.len(); + for f in module.functions.iter().filter(|f| !f.fallible) { + if keep.contains(f.name.as_str()) { + for c in crate::ir::expression_callees(&f.body) { + if checked.contains(c) { + keep.insert(c); + } + } + } + } + if keep.len() == before { + return keep; + } + } +} + +/// The checked copy of a helper (SPEC-helper-check-propagation §2.1, §2.6): the same body, +/// emitted as a fallible one, so every checked operation fails with `-3` at the operator +/// (DP-P4) and the caller propagates it. Its callees in the set are called through THEIR copies, +/// because the context says so — which is how the check reaches a chain of any depth. +/// +/// The name is `ml_ck_` (DP-P7). User functions are always `ml_fn_` and parameters +/// and locals may not start with `ml_`, so no legal program can name the same thing. The +/// prototype used `ml_fn___twin_`, and `fn __twin_mul` beside `fn mul` failed with E0428. +fn emit_checked_copy( + f: &IrFunction, + checked: &BTreeSet, + out: &mut String, +) -> Result<(), CodegenError> { + // Only infallible functions are members, and an infallible function returns a scalar: + // a string or array return needs the caller's buffer and is always `!`. + if f.fallible || matches!(f.ret, IrType::Array(_) | IrType::Str) { + return Err(CodegenError::new(format!( + "internal: '{}' cannot have a checked copy (fallible, or a buffer return)", + f.name + ))); + } + if !block_always_returns(&f.body) { + return Err(CodegenError::new(format!( + "function '{}' may not return on all paths", + f.name + ))); + } + let _ = writeln!( + out, + "fn ml_ck_{}({}) -> Result<{}, i32> {{", + f.name, + rust_params(&f.params).join(", "), + rust_type(f.ret) + ); + let cx = Cx { + abi: RetAbi::Fallible, + checked, + }; + for s in &f.body { + emit_stmt(s, 1, cx, out); + } + out.push_str("}\n"); + Ok(()) +} + +fn emit_function( + f: &IrFunction, + checked: &BTreeSet, + out: &mut String, +) -> Result<(), CodegenError> { // An array return has no `return` statement -- the value IS the caller's buffer, and the // status is emitted after the body. Typeck exempts it for the same reason; this is the // backend half of that exemption, and leaving it out made every correct program fail here @@ -238,7 +331,7 @@ fn emit_function(f: &IrFunction, out: &mut String) -> Result<(), CodegenError> { // only because the `mlx_` prefix hides them from rustc's keyword list) and why the // internal-name reserved-word check loses its reason to exist. let mut body_params = params.clone(); - let (body_ret, abi) = if let IrType::Array(elem) = f.ret { + let (body_ret, shape) = if let IrType::Array(elem) = f.ret { // The same triple as a string return, and for the same reason (RetAbi::ArrayOut): the // elements have nowhere to live until they are in the caller's buffer. The pointer is // `*mut` of the ELEMENT type, so `[f64]` writes 8-byte strides and `[bool]` 1-byte @@ -268,13 +361,17 @@ fn emit_function(f: &IrFunction, out: &mut String) -> Result<(), CodegenError> { body_params.join(", "), body_ret ); + let cx = Cx { + abi: shape, + checked, + }; for s in &f.body { - emit_stmt(s, 1, abi, out); + emit_stmt(s, 1, cx, out); } // An array body has no `return`: reaching the end IS success, and the status says so. // Every failure before this point has already returned -- truncation, an out-of-range // write, or a `fail` placed before `result` (2.4b refuses one placed after it). - if matches!(abi, RetAbi::ArrayOut(_)) { + if matches!(shape, RetAbi::ArrayOut(_)) { out.push_str(" 0\n"); } out.push_str("}\n"); @@ -417,10 +514,10 @@ fn rust_params(params: &[IrParam]) -> Vec { /// /// An array can only be a parameter — there are no array locals, literals or array-valued /// calls — so an array argument is always a `Var` naming one, and `_len` is in scope. -fn emit_call_args(args: &[IrExpr], abi: RetAbi) -> String { +fn emit_call_args(args: &[IrExpr], cx: Cx<'_>) -> String { let mut out = Vec::with_capacity(args.len()); for a in args { - out.push(emit_expr(a, abi)); + out.push(emit_expr(a, cx)); if matches!(a.ty, IrType::Array(_)) { let IrExprKind::Var(name) = &a.kind else { unreachable!("an array argument that is not a parameter: {:?}", a.kind); @@ -495,7 +592,19 @@ impl RetAbi { } } -fn emit_stmt(s: &IrStmt, indent: usize, abi: RetAbi, out: &mut String) { +/// What a statement or expression is emitted INTO: the body's return shape, and the module's +/// set of helpers that are checked under a fallible caller (`ir::checked_helpers`, +/// SPEC-helper-check-propagation DP-P6). The set is the one piece of module-level knowledge an +/// expression needs — a call site in a fallible body must know whether its callee has a checked +/// copy — so it rides along with the shape instead of living in global state. +#[derive(Clone, Copy)] +struct Cx<'a> { + abi: RetAbi, + checked: &'a BTreeSet, +} + +fn emit_stmt(s: &IrStmt, indent: usize, cx: Cx<'_>, out: &mut String) { + let abi = cx.abi; let pad = " ".repeat(indent); match s { // AR1 landed the surface; AR3 lands the lowering. `emit` refuses a module carrying @@ -531,7 +640,7 @@ fn emit_stmt(s: &IrStmt, indent: usize, abi: RetAbi, out: &mut String) { IrArrayElem::Bool => "false", IrArrayElem::I32 => "0", }; - let _ = writeln!(out, "{pad}let __n = {};", emit_expr(n, abi)); + let _ = writeln!(out, "{pad}let __n = {};", emit_expr(n, cx)); let _ = writeln!(out, "{pad}let __n = if __n < 0 {{ 0 }} else {{ __n }};"); let _ = writeln!(out, "{pad}unsafe {{ *ml_needed = __n; }}"); let _ = writeln!( @@ -557,7 +666,7 @@ fn emit_stmt(s: &IrStmt, indent: usize, abi: RetAbi, out: &mut String) { // declared one is what makes the error mean "you wrote outside your own result". IrStmt::ResultSet { index, value } => { let _ = writeln!(out, "{pad}{{"); - let _ = writeln!(out, "{pad} let __i = {};", emit_expr(index, abi)); + let _ = writeln!(out, "{pad} let __i = {};", emit_expr(index, cx)); let _ = writeln!( out, "{pad} if __i < 0 || __i >= __n {{ return {}; }}", @@ -566,7 +675,7 @@ fn emit_stmt(s: &IrStmt, indent: usize, abi: RetAbi, out: &mut String) { let _ = writeln!( out, "{pad} unsafe {{ *ml_buf.add(__i as usize) = {}; }}", - emit_expr(value, abi) + emit_expr(value, cx) ); let _ = writeln!(out, "{pad}}}"); } @@ -575,10 +684,10 @@ fn emit_stmt(s: &IrStmt, indent: usize, abi: RetAbi, out: &mut String) { // another Mathless function through `try` — can tell success from a propagated // status without a sentinel value. RetAbi::Fallible => { - let _ = writeln!(out, "{pad}return Ok({});", emit_expr(e, abi)); + let _ = writeln!(out, "{pad}return Ok({});", emit_expr(e, cx)); } RetAbi::Plain => { - let _ = writeln!(out, "{pad}return {};", emit_expr(e, abi)); + let _ = writeln!(out, "{pad}return {};", emit_expr(e, cx)); } // An array-returning function has no `return `: the value IS the host's // buffer, which `result[i] = v` fills. Typeck refuses the statement, so reaching @@ -589,7 +698,7 @@ fn emit_stmt(s: &IrStmt, indent: usize, abi: RetAbi, out: &mut String) { // A string return IS the write into the caller's buffer. RetAbi::StringOut => match &e.kind { // Built here: pieces appended in order (SPEC-string-concat §2.3). - IrExprKind::Concat(pieces) => emit_concat_return(pieces, indent, out), + IrExprKind::Concat(pieces) => emit_concat_return(pieces, indent, cx, out), // A lone span goes through the SAME emitter as a concatenation, as a // one-piece list. It could have had its own path, but then the length-bounded // copy would exist in two places and only one of them would be covered by the @@ -597,12 +706,12 @@ fn emit_stmt(s: &IrStmt, indent: usize, abi: RetAbi, out: &mut String) { // "a new string-shaped one would be handed to `ml_strout` as if it were an // address" — and a span has no NUL at `to`, so that would return the suffix. IrExprKind::ByteSlice { .. } => { - emit_concat_return(core::slice::from_ref(e), indent, out) + emit_concat_return(core::slice::from_ref(e), indent, cx, out) } // And so does a lone `fixed(x, n)`, for the same reason and one more: it is // not a pointer at all. `ml_strout` would take the f64's bits as an address. IrExprKind::Fixed { .. } => { - emit_concat_return(core::slice::from_ref(e), indent, out) + emit_concat_return(core::slice::from_ref(e), indent, cx, out) } // Borrowed: one pointer, the #92 path, unchanged. Spelled out rather than // `_`, because "everything else is already a pointer" is a fact about @@ -626,7 +735,7 @@ fn emit_stmt(s: &IrStmt, indent: usize, abi: RetAbi, out: &mut String) { let _ = writeln!( out, "{pad}return ml_strout({}, ml_buf, ml_cap, ml_needed);", - emit_expr(e, abi) + emit_expr(e, cx) ); } }, @@ -659,7 +768,7 @@ fn emit_stmt(s: &IrStmt, indent: usize, abi: RetAbi, out: &mut String) { IrStmt::TryCall { dest, callee, args, .. } => { - let call = format!("ml_fn_{callee}({})", emit_call_args(args, abi)); + let call = format!("ml_fn_{callee}({})", emit_call_args(args, cx)); let prop = abi.propagate(); match dest { IrTryDest::Let { name, mutable } => { @@ -700,7 +809,7 @@ fn emit_stmt(s: &IrStmt, indent: usize, abi: RetAbi, out: &mut String) { kind: IrExprKind::Var("__v".to_string()), }), indent + 2, - abi, + cx, out, ); let _ = writeln!(out, "{pad} }}"); @@ -716,7 +825,7 @@ fn emit_stmt(s: &IrStmt, indent: usize, abi: RetAbi, out: &mut String) { } => { // Internal binding — a plain Rust `let`, never an export. let kw = if *mutable { "let mut" } else { "let" }; - let _ = writeln!(out, "{pad}{kw} {name} = {};", emit_expr(value, abi)); + let _ = writeln!(out, "{pad}{kw} {name} = {};", emit_expr(value, cx)); } IrStmt::AssignOut { name, value } => { // Same shape as the D17 success write: a raw store through the caller's pointer. @@ -726,19 +835,19 @@ fn emit_stmt(s: &IrStmt, indent: usize, abi: RetAbi, out: &mut String) { let _ = writeln!( out, "{pad}unsafe {{ *{name} = {}; }}", - emit_expr(value, abi) + emit_expr(value, cx) ); } IrStmt::Assign { name, value } => { // Reassign an in-scope `let mut`. Inside an `if` this mutates the OUTER binding, // which is the point of the slice: `if` is a statement, so a mutable local is how // a branch result is collected. - let _ = writeln!(out, "{pad}{name} = {};", emit_expr(value, abi)); + let _ = writeln!(out, "{pad}{name} = {};", emit_expr(value, cx)); } IrStmt::If { cond, body } => { - let _ = writeln!(out, "{pad}if {} {{", emit_expr(cond, abi)); + let _ = writeln!(out, "{pad}if {} {{", emit_expr(cond, cx)); for st in body { - emit_stmt(st, indent + 1, abi, out); + emit_stmt(st, indent + 1, cx, out); } let _ = writeln!(out, "{pad}}}"); } @@ -746,9 +855,9 @@ fn emit_stmt(s: &IrStmt, indent: usize, abi: RetAbi, out: &mut String) { // A module export may now fail to return (SPEC-while §5.1). There is nothing to // emit for that: no fuel counter without the VM R01 rejected, no timeout without a // runtime. The contract is documented in HOST_ABI instead. - let _ = writeln!(out, "{pad}while {} {{", emit_expr(cond, abi)); + let _ = writeln!(out, "{pad}while {} {{", emit_expr(cond, cx)); for st in body { - emit_stmt(st, indent + 1, abi, out); + emit_stmt(st, indent + 1, cx, out); } let _ = writeln!(out, "{pad}}}"); } @@ -768,7 +877,8 @@ fn overflow_bail(abi: RetAbi) -> Option { } } -fn emit_expr(e: &IrExpr, abi: RetAbi) -> String { +fn emit_expr(e: &IrExpr, cx: Cx<'_>) -> String { + let abi = cx.abi; match &e.kind { // `xs[i]`, bounds-checked (SPEC-array-input 2.3). // @@ -795,7 +905,7 @@ fn emit_expr(e: &IrExpr, abi: RetAbi) -> String { }; format!( "{{ let __i = {}; if __i < 0 || __i >= {array}_len {{ {bail} }} unsafe {{ *{array}.add(__i as usize) }} }}", - emit_expr(index, abi) + emit_expr(index, cx) ) } // The companion, read straight out. Cannot fail, so no block and no early return. @@ -803,7 +913,7 @@ fn emit_expr(e: &IrExpr, abi: RetAbi) -> String { // `byte_len(s)` — the same NUL walk `==` and concatenation already do, exposed // (SPEC-string-length DP-L1). The terminator is not counted: `""` is 0, not 1, which // is deliberately a different number from Q12's `ml_needed` (§2.1). - IrExprKind::ByteLen(operand) => format!("ml_slen({})", emit_expr(operand, abi)), + IrExprKind::ByteLen(operand) => format!("ml_slen({})", emit_expr(operand, cx)), // A concatenation is not an expression in the emitted Rust: it has no value until it // is written into the caller's buffer, which is what `emit_concat_return` does. Typeck // confines it to `return` (DP-K3) precisely so this arm is unreachable. @@ -853,7 +963,7 @@ fn emit_expr(e: &IrExpr, abi: RetAbi) -> String { IrExprKind::ConstBool(b) => b.to_string(), IrExprKind::Var(name) => name.clone(), IrExprKind::Call { name, args } => { - let args = emit_call_args(args, abi); + let args = emit_call_args(args, cx); // A built-in rounder lowers to its `ml_`-prefixed helper (emitted above). That is // safe because `reserved::generated_prefix` rejects `ml_` on PARAMETERS and // LOCALS, which are still emitted raw — when this comment once claimed the prefix @@ -865,6 +975,14 @@ fn emit_expr(e: &IrExpr, abi: RetAbi) -> String { // function is `ml_fn_`, which cannot collide with `ml_floor` (SPEC-export-wrappers DP-W4). if crate::typeck::Rounder::from_name(name).is_some() { format!("ml_{name}({args})") + } else if !matches!(abi, RetAbi::Plain) && cx.checked.contains(name) { + // SPEC-helper-check-propagation §2.1: under a fallible caller a checked helper + // runs as its checked copy, and its `-3` leaves this body the way a propagated + // `try` does — here, in source order, where the call stands (DP-P4). + format!( + "(match ml_ck_{name}({args}) {{ Ok(__v) => __v, Err(__e) => {{ {}; }} }})", + abi.propagate() + ) } else { // Every Mathless function is one body named `ml_fn_`, exported or not, // so a call site no longer has to know which it is. Before the wrapper @@ -895,11 +1013,11 @@ fn emit_expr(e: &IrExpr, abi: RetAbi) -> String { if let Some(bail) = overflow_bail(abi) { return format!( "{{ let __c = {}; if !(__c > -2147483649.0f64 && __c < 2147483648.0f64) {{ {bail} }} __c as i32 }}", - emit_expr(operand, abi) + emit_expr(operand, cx) ); } } - format!("({} as {})", emit_expr(operand, abi), rust_type(*to)) + format!("({} as {})", emit_expr(operand, cx), rust_type(*to)) } IrExprKind::Unary { op, operand } => { // Backend safety net for directly-built IR, in the same spirit as @@ -922,16 +1040,16 @@ fn emit_expr(e: &IrExpr, abi: RetAbi) -> String { if let Some(bail) = overflow_bail(abi) { return format!( "(match ({}).checked_neg() {{ Some(__o) => __o, None => {{ {bail} }} }})", - emit_expr(operand, abi) + emit_expr(operand, cx) ); } - return format!("({}).wrapping_neg()", emit_expr(operand, abi)); + return format!("({}).wrapping_neg()", emit_expr(operand, cx)); } let sym = match op { IrUnOp::Neg => "-", IrUnOp::Not => "!", }; - format!("({sym}{})", emit_expr(operand, abi)) + format!("({sym}{})", emit_expr(operand, cx)) } IrExprKind::Binary { op, lhs, rhs } => { // Directly-built IR could put `&&`/`||` on non-bool operands. Note this is a @@ -980,8 +1098,8 @@ fn emit_expr(e: &IrExpr, abi: RetAbi) -> String { if let Some(bail) = overflow_bail(abi) { return format!( "{{ let __l = {}; let __d = {}; if __d == 0 {{ 0i32 }} else if __d == -1 && __l == i32::MIN {{ {bail} }} else {{ __l.wrapping_div(__d) }} }}", - emit_expr(lhs, abi), - emit_expr(rhs, abi) + emit_expr(lhs, cx), + emit_expr(rhs, cx) ); } } @@ -992,8 +1110,8 @@ fn emit_expr(e: &IrExpr, abi: RetAbi) -> String { }; return format!( "{{ let __l = {}; let __d = {}; if __d == 0 {{ 0i32 }} else {{ __l.{}(__d) }} }}", - emit_expr(lhs, abi), - emit_expr(rhs, abi), + emit_expr(lhs, cx), + emit_expr(rhs, cx), method ); } @@ -1038,15 +1156,11 @@ fn emit_expr(e: &IrExpr, abi: RetAbi) -> String { let checked = method.replace("wrapping_", "checked_"); return format!( "(match ({}).{checked}({}) {{ Some(__o) => __o, None => {{ {bail} }} }})", - emit_expr(lhs, abi), - emit_expr(rhs, abi) + emit_expr(lhs, cx), + emit_expr(rhs, cx) ); } - return format!( - "({}).{method}({})", - emit_expr(lhs, abi), - emit_expr(rhs, abi) - ); + return format!("({}).{method}({})", emit_expr(lhs, cx), emit_expr(rhs, cx)); } } // A string is a `*const u8` (SPEC-string-input DP-S1). Rust's `==` on raw pointers compares the @@ -1100,13 +1214,13 @@ fn emit_expr(e: &IrExpr, abi: RetAbi) -> String { "{{ let __ss = {}; let __sf = {}; \ let __sl = ml_sublen(__ss, __sf, {}); \ if __sl < 0 {{ {bail} }} ml_subeq(__ss, __sf, __sl, {}) }}", - emit_expr(s, abi), - emit_expr(from, abi), - emit_expr(to, abi), - emit_expr(other, abi) + emit_expr(s, cx), + emit_expr(from, cx), + emit_expr(to, cx), + emit_expr(other, cx) ) } - None => format!("ml_streq({}, {})", emit_expr(lhs, abi), emit_expr(rhs, abi)), + None => format!("ml_streq({}, {})", emit_expr(lhs, cx), emit_expr(rhs, cx)), }; return if matches!(op, IrBinOp::Eq) { call @@ -1116,9 +1230,9 @@ fn emit_expr(e: &IrExpr, abi: RetAbi) -> String { } format!( "({} {} {})", - emit_expr(lhs, abi), + emit_expr(lhs, cx), op_str(*op), - emit_expr(rhs, abi) + emit_expr(rhs, cx) ) } } @@ -1264,7 +1378,12 @@ fn piece_kind(p: &IrExpr) -> PieceKind<'_> { } } -fn emit_concat_return(pieces: &[IrExpr], indent: usize, out: &mut String) { +fn emit_concat_return(pieces: &[IrExpr], indent: usize, cx: Cx<'_>, out: &mut String) { + // Every piece is emitted into a string body, whatever called this. + let cx = Cx { + abi: RetAbi::StringOut, + ..cx + }; let pad = " ".repeat(indent); // Each piece is bound ONCE, before either pass, and both passes use the binding. // @@ -1280,8 +1399,8 @@ fn emit_concat_return(pieces: &[IrExpr], indent: usize, out: &mut String) { let e = match piece_kind(p) { // A concat only ever exists in a `-> string!` body, which is StringOut by // construction (SPEC-string-concat 2.3). - PieceKind::Digits { operand } => emit_expr(operand, RetAbi::StringOut), - PieceKind::Bytes => emit_expr(p, RetAbi::StringOut), + PieceKind::Digits { operand } => emit_expr(operand, cx), + PieceKind::Bytes => emit_expr(p, cx), // A span binds three values, not one, and is emitted separately below so the // offsets are evaluated exactly once — the same guarantee the note above earns // for the other kinds. `fixed` is the same: two values, and a scaling step whose @@ -1295,21 +1414,9 @@ fn emit_concat_return(pieces: &[IrExpr], indent: usize, out: &mut String) { // D17 says a failed call leaves `*ml_needed` alone, so the check cannot come later. for (i, p) in pieces.iter().enumerate() { if let PieceKind::Span { s, from, to } = piece_kind(p) { - let _ = writeln!( - out, - "{pad}let __s{i} = {};", - emit_expr(s, RetAbi::StringOut) - ); - let _ = writeln!( - out, - "{pad}let __f{i} = {};", - emit_expr(from, RetAbi::StringOut) - ); - let _ = writeln!( - out, - "{pad}let __t{i} = {};", - emit_expr(to, RetAbi::StringOut) - ); + let _ = writeln!(out, "{pad}let __s{i} = {};", emit_expr(s, cx)); + let _ = writeln!(out, "{pad}let __f{i} = {};", emit_expr(from, cx)); + let _ = writeln!(out, "{pad}let __t{i} = {};", emit_expr(to, cx)); let _ = writeln!(out, "{pad}let __l{i} = ml_sublen(__s{i}, __f{i}, __t{i});"); let _ = writeln!( out, @@ -1335,16 +1442,8 @@ fn emit_concat_return(pieces: &[IrExpr], indent: usize, out: &mut String) { // the alternative is one loop that must then re-derive which kind it is looking at. for (i, p) in pieces.iter().enumerate() { if let PieceKind::Decimal { x, places } = piece_kind(p) { - let _ = writeln!( - out, - "{pad}let __x{i} = {};", - emit_expr(x, RetAbi::StringOut) - ); - let _ = writeln!( - out, - "{pad}let __k{i} = {};", - emit_expr(places, RetAbi::StringOut) - ); + let _ = writeln!(out, "{pad}let __x{i} = {};", emit_expr(x, cx)); + let _ = writeln!(out, "{pad}let __k{i} = {};", emit_expr(places, cx)); let _ = writeln!( out, "{pad}let (__v{i}, __ok{i}) = ml_fixscale(__x{i}, __k{i});" diff --git a/compiler/src/header.rs b/compiler/src/header.rs index d9c113a..08f64a5 100644 --- a/compiler/src/header.rs +++ b/compiler/src/header.rs @@ -144,13 +144,14 @@ fn can_report_out_of_range(module: &IrModule) -> bool { } /// Can any FALLIBLE function in this module answer `ML_ST_OVERFLOW`? One predicate for both -/// bindings, for the reason [`can_report_out_of_range`] gives. Infallible bodies are not asked: -/// they keep wrapping and have no status to report it with (SPEC-checked-arithmetic DP-O1). +/// bindings, for the reason [`can_report_out_of_range`] gives — and the same set codegen reads to +/// decide which helpers get a checked copy (SPEC-helper-check-propagation DP-P6). +/// +/// It asked only `f.fallible && can_fail_overflow(&f.body)` while the rule was the body's. Once a +/// helper under a `!` caller is checked too, that answered `false` for a module that returns `-3` +/// through the helper: measured on the prototype, both bindings stayed silent. fn can_report_overflow(module: &IrModule) -> bool { - module - .functions - .iter() - .any(|f| f.fallible && crate::ir::can_fail_overflow(&f.body)) + crate::ir::module_can_fail_overflow(module) } /// Does this module RETURN any byte outside ASCII? @@ -354,12 +355,15 @@ pub fn emit_c_header(module: &IrModule, dll_name: &str) -> String { if can_report_overflow(module) { let _ = writeln!( s, - "/* A fallible function's i32 arithmetic or f64-to-i32 conversion has no i32 value \ - (overflow," + "/* i32 arithmetic or an f64-to-i32 conversion that a fallible function executes -- in" + ); + let _ = writeln!( + s, + " * its own body or in any function it calls -- has no i32 value (overflow, or NaN)." ); let _ = writeln!( s, - " * or NaN). The out-parameter is NOT written (D17); calling again gives the same answer. */" + " * The out-parameter is NOT written (D17); calling again gives the same answer. */" ); let _ = writeln!(s, "#ifndef ML_ST_OVERFLOW"); let _ = writeln!(s, "#define ML_ST_OVERFLOW ({})", crate::abi::ML_ST_OVERFLOW); @@ -850,9 +854,13 @@ pub fn emit_delphi_unit(module: &IrModule, dll_name: &str) -> String { if can_report_overflow(module) { let _ = writeln!( s, - " {{ A fallible function's i32 arithmetic or f64-to-i32 conversion has no i32 value\n \ - (overflow, or NaN). The out-parameter is NOT written (D17). }}" + " {{ i32 arithmetic or an f64-to-i32 conversion that a fallible function executes --" + ); + let _ = writeln!( + s, + " in its own body or in any function it calls -- has no i32 value (overflow, or NaN)." ); + let _ = writeln!(s, " The out-parameter is NOT written (D17). }}"); let _ = writeln!(s, " ML_ST_OVERFLOW = {};", crate::abi::ML_ST_OVERFLOW); } s.push('\n'); diff --git a/compiler/src/ir.rs b/compiler/src/ir.rs index 80f762b..1ef0b86 100644 --- a/compiler/src/ir.rs +++ b/compiler/src/ir.rs @@ -6,6 +6,8 @@ //! //! Every [`IrExpr`] carries its resolved [`IrType`], so the backend never re-infers types. +use std::collections::{BTreeMap, BTreeSet}; + #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub enum IrType { /// Lowered as `*const u8`: borrowed for the call (D16 rule 1), never owned. @@ -617,6 +619,148 @@ pub fn can_fail_overflow(body: &[IrStmt]) -> bool { }) } +/// The functions a body calls in expression position, in order, with repeats. +/// +/// Expression-position calls are the only calls to INFALLIBLE functions: calling a fallible +/// one takes a `try` statement, whose callee is not listed here — but whose arguments are +/// walked, because a helper call can sit inside them. Built-in rounders are `Call`s too; they +/// name no module function, so a lookup by name simply misses them. +pub fn expression_callees(body: &[IrStmt]) -> Vec<&str> { + fn in_expr<'a>(e: &'a IrExpr, out: &mut Vec<&'a str>) { + match &e.kind { + IrExprKind::Call { name, args } => { + out.push(name.as_str()); + args.iter().for_each(|a| in_expr(a, out)); + } + IrExprKind::Concat(args) => args.iter().for_each(|a| in_expr(a, out)), + IrExprKind::Binary { lhs, rhs, .. } => { + in_expr(lhs, out); + in_expr(rhs, out); + } + IrExprKind::Unary { operand, .. } + | IrExprKind::Cast { operand, .. } + | IrExprKind::ByteLen(operand) => in_expr(operand, out), + IrExprKind::Index { index, .. } => in_expr(index, out), + IrExprKind::ByteSlice { s, from, to } => { + in_expr(s, out); + in_expr(from, out); + in_expr(to, out); + } + IrExprKind::Fixed { x, places } => { + in_expr(x, out); + in_expr(places, out); + } + IrExprKind::Len { .. } + | IrExprKind::ConstF64(_) + | IrExprKind::ConstStr(_) + | IrExprKind::ConstI32(_) + | IrExprKind::ConstBool(_) + | IrExprKind::Var(_) => {} + } + } + fn in_stmts<'a>(body: &'a [IrStmt], out: &mut Vec<&'a str>) { + for s in body { + match s { + IrStmt::If { cond, body } | IrStmt::While { cond, body } => { + in_expr(cond, out); + in_stmts(body, out); + } + IrStmt::ResultSet { index, value } => { + in_expr(index, out); + in_expr(value, out); + } + IrStmt::ResultLen(e) + | IrStmt::Return(e) + | IrStmt::Let { value: e, .. } + | IrStmt::Assign { value: e, .. } + | IrStmt::AssignOut { value: e, .. } => in_expr(e, out), + IrStmt::TryCall { args, .. } => args.iter().for_each(|a| in_expr(a, out)), + IrStmt::Fail(_) => {} + } + } + } + let mut out = Vec::new(); + in_stmts(body, &mut out); + out +} + +/// The infallible functions whose arithmetic a fallible body executes and that can overflow — +/// `SPEC-helper-check-propagation` §2.5 (DP-P6). **One set, three readers**: codegen gives each +/// member a checked copy and calls it from fallible bodies, and both bindings declare +/// `ML_ST_OVERFLOW` through [`module_can_fail_overflow`], which asks this set. Two walks would be +/// two answers, and the prototype measured what that costs: the module returned `-3` through a +/// helper while neither binding named it (#238's defect, one hop further out). +/// +/// A member is (1) reached from a fallible body through calls into infallible functions — +/// exported ones included (DP-P2): `export` decides host visibility, not internal meaning — and +/// (2) able to overflow in its own body or in an infallible function it calls. The call graph +/// is acyclic (recursion is rejected, SPEC-calls), so both walks terminate. +pub fn checked_helpers(module: &IrModule) -> BTreeSet { + let infallible: BTreeMap<&str, &IrFunction> = module + .functions + .iter() + .filter(|f| !f.fallible) + .map(|f| (f.name.as_str(), f)) + .collect(); + + // (2) Can this infallible function overflow, itself or below? Memoised over the DAG. + fn can<'a>( + name: &'a str, + infallible: &BTreeMap<&'a str, &'a IrFunction>, + memo: &mut BTreeMap<&'a str, bool>, + ) -> bool { + if let Some(&v) = memo.get(name) { + return v; + } + let Some(f) = infallible.get(name) else { + return false; + }; + let v = can_fail_overflow(&f.body) + || expression_callees(&f.body) + .into_iter() + .any(|c| can(c, infallible, memo)); + memo.insert(name, v); + v + } + + // (1) Everything reachable from a fallible body through infallible callees. + let mut reached: BTreeSet<&str> = BTreeSet::new(); + let mut stack: Vec<&str> = module + .functions + .iter() + .filter(|f| f.fallible) + .flat_map(|f| expression_callees(&f.body)) + .collect(); + while let Some(name) = stack.pop() { + if let Some(f) = infallible.get(name) { + if reached.insert(name) { + stack.extend(expression_callees(&f.body)); + } + } + } + + let mut memo = BTreeMap::new(); + reached + .into_iter() + .filter(|name| can(name, &infallible, &mut memo)) + .map(str::to_string) + .collect() +} + +/// Can this module return `ML_ST_OVERFLOW`? A fallible body holds a checked operation of its own, +/// or calls a member of [`checked_helpers`] — the only two places `-3` comes from. The bindings' +/// one predicate (SPEC-helper-check-propagation §2.5). A `try` callee needs no walk: it is a +/// fallible function, so its own body is one of the bodies asked. +pub fn module_can_fail_overflow(module: &IrModule) -> bool { + let checked = checked_helpers(module); + module.functions.iter().filter(|f| f.fallible).any(|f| { + can_fail_overflow(&f.body) + || expression_callees(&f.body) + .into_iter() + .any(|c| checked.contains(c)) + }) +} + /// Does this body compare a span anywhere? — `SPEC-string-slice-compare` DP-C2. /// /// The companion to [`first_index`], and it exists for the same stated reason: `check_expr` diff --git a/compiler/src/reserved.rs b/compiler/src/reserved.rs index 120a92d..8338f0f 100644 --- a/compiler/src/reserved.rs +++ b/compiler/src/reserved.rs @@ -5,9 +5,10 @@ //! produce invalid output. The frontend rejects such names with a clear error — a single //! check that protects all backends (WBS hardening). //! -//! **Exported** function names are safe without this check: they are emitted with the `mlx_` -//! prefix. **Internal** function names are not — since SPEC-calls they are emitted as-is — -//! so `typeck` runs them through here too. +//! **Function** names are safe without this check: every function body is emitted as +//! `ml_fn_` (SPEC-export-wrappers DP-W4, #101), its export adapter as `mlx_`, and a +//! checked copy as `ml_ck_` (SPEC-helper-check-propagation DP-P7) — prefixes no parameter +//! or local may take (see [`generated_prefix`]). //! //! Target keywords are only half the problem. codegen also injects its OWN identifiers into //! the same emitted scope, and those need reserving as well — see [`generated_prefix`]. diff --git a/compiler/tests/bindings.rs b/compiler/tests/bindings.rs index c6b8e13..95d97bf 100644 --- a/compiler/tests/bindings.rs +++ b/compiler/tests/bindings.rs @@ -203,6 +203,15 @@ fn each_conditional_binding_block_fires_for_exactly_the_shapes_that_need_it() { &[H_OVF], &[pas_ovf], ), + // The same status reached ONLY through a helper: the fallible body does no arithmetic + // of its own. The predicate that looked at that body alone said "absent" here while + // the module returned -3 (SPEC-helper-check-propagation §2.5). + ( + "helper overflow", + "fn sq(x: i32) -> i32 { return x * x }\nexport fn f(x: i32) -> i32! { return sq(x) }", + &[H_OVF], + &[pas_ovf], + ), // A string parameter with a scalar return: only the Delphi note, because only Delphi // has a way to get this wrong silently (measured 2026-09-10, DP-S2). ( diff --git a/compiler/tests/emit_robustness.rs b/compiler/tests/emit_robustness.rs index 39fbba9..d8e83f0 100644 --- a/compiler/tests/emit_robustness.rs +++ b/compiler/tests/emit_robustness.rs @@ -566,6 +566,45 @@ fn a_build_ignores_whatever_cargo_variables_are_already_set() { let _ = std::fs::remove_dir_all(&dir); } +/// **SPEC-helper-check-propagation acceptance G(2): checked copies leave no dead code.** +/// +/// One helper is called from both kinds of body (it needs its plain body AND its checked copy), +/// one only from a fallible body (its plain body would be dead). Built under an ambient +/// `RUSTFLAGS=-D warnings`, any body nobody calls is a hard error. The prototype emitted a copy +/// for every helper and kept every plain body, and `examples/discount4.mls` stopped building this +/// way (measured). The snake_case names are deliberate: a non-snake_case user name trips +/// `non_snake_case` under this flag today, which is recorded in the SPEC §5 and is not this +/// test's subject. +#[cfg(windows)] +#[test] +fn checked_copies_build_under_an_ambient_deny_warnings() { + let dir = common::TempOut::new("ckdw"); + let src = dir.join("ckdw.mls"); + std::fs::write( + &src, + "fn sq(x: i32) -> i32 { return x * x }\n\ + fn cube(x: i32) -> i32 { return x * x * x }\n\ + export fn f(x: i32) -> i32! { return sq(x) + cube(x) }\n\ + export fn g(x: i32) -> i32 { return sq(x) }\n", + ) + .expect("write src"); + let out = dir.join("out"); + let r = std::process::Command::new(env!("CARGO_BIN_EXE_mlc")) + .args(["build".as_ref(), src.as_os_str()]) + .arg("-o") + .arg(&out) + .env("RUSTFLAGS", "-D warnings") + .output() + .expect("run mlc"); + assert!( + r.status.success(), + "a module with checked helpers must build under RUSTFLAGS=-D warnings:\n{}\n{}", + String::from_utf8_lossy(&r.stdout), + String::from_utf8_lossy(&r.stderr) + ); + assert!(out.join("ckdw.dll").exists(), "missing ckdw.dll"); +} + /// A module name has a maximum length, and the boundary is checked from both sides. /// /// The name becomes the suffix of a reserved export (`ml_iface_hash_`) since diff --git a/compiler/tests/golden/headers.h.txt b/compiler/tests/golden/headers.h.txt index f9608ef..8a201b1 100644 --- a/compiler/tests/golden/headers.h.txt +++ b/compiler/tests/golden/headers.h.txt @@ -44,8 +44,9 @@ #ifndef ML_ST_INDEX_OUT_OF_RANGE #define ML_ST_INDEX_OUT_OF_RANGE (-2) #endif -/* A fallible function's i32 arithmetic or f64-to-i32 conversion has no i32 value (overflow, - * or NaN). The out-parameter is NOT written (D17); calling again gives the same answer. */ +/* i32 arithmetic or an f64-to-i32 conversion that a fallible function executes -- in + * its own body or in any function it calls -- has no i32 value (overflow, or NaN). + * The out-parameter is NOT written (D17); calling again gives the same answer. */ #ifndef ML_ST_OVERFLOW #define ML_ST_OVERFLOW (-3) #endif @@ -164,8 +165,9 @@ int32_t mlx_add(int32_t /* a */, int32_t /* b */); #ifndef ML_ST_INDEX_OUT_OF_RANGE #define ML_ST_INDEX_OUT_OF_RANGE (-2) #endif -/* A fallible function's i32 arithmetic or f64-to-i32 conversion has no i32 value (overflow, - * or NaN). The out-parameter is NOT written (D17); calling again gives the same answer. */ +/* i32 arithmetic or an f64-to-i32 conversion that a fallible function executes -- in + * its own body or in any function it calls -- has no i32 value (overflow, or NaN). + * The out-parameter is NOT written (D17); calling again gives the same answer. */ #ifndef ML_ST_OVERFLOW #define ML_ST_OVERFLOW (-3) #endif @@ -222,8 +224,9 @@ int32_t mlx_in_stock(const int32_t* /* stock */, int32_t /* stock_len */, bool* #ifndef ML_ST_INDEX_OUT_OF_RANGE #define ML_ST_INDEX_OUT_OF_RANGE (-2) #endif -/* A fallible function's i32 arithmetic or f64-to-i32 conversion has no i32 value (overflow, - * or NaN). The out-parameter is NOT written (D17); calling again gives the same answer. */ +/* i32 arithmetic or an f64-to-i32 conversion that a fallible function executes -- in + * its own body or in any function it calls -- has no i32 value (overflow, or NaN). + * The out-parameter is NOT written (D17); calling again gives the same answer. */ #ifndef ML_ST_OVERFLOW #define ML_ST_OVERFLOW (-3) #endif @@ -904,8 +907,9 @@ int32_t mlx_negate_if(int32_t /* x */, bool /* flip */); #ifndef ML_ST_INDEX_OUT_OF_RANGE #define ML_ST_INDEX_OUT_OF_RANGE (-2) #endif -/* A fallible function's i32 arithmetic or f64-to-i32 conversion has no i32 value (overflow, - * or NaN). The out-parameter is NOT written (D17); calling again gives the same answer. */ +/* i32 arithmetic or an f64-to-i32 conversion that a fallible function executes -- in + * its own body or in any function it calls -- has no i32 value (overflow, or NaN). + * The out-parameter is NOT written (D17); calling again gives the same answer. */ #ifndef ML_ST_OVERFLOW #define ML_ST_OVERFLOW (-3) #endif @@ -975,8 +979,9 @@ int32_t mlx_state_label(int32_t /* state */, char* ml_buf, int32_t ml_cap, int32 * type, parameter order, out-ness or error code does. */ #define ML_PACK_IFACE_HASH 0xDA0077B3A04515ABULL -/* A fallible function's i32 arithmetic or f64-to-i32 conversion has no i32 value (overflow, - * or NaN). The out-parameter is NOT written (D17); calling again gives the same answer. */ +/* i32 arithmetic or an f64-to-i32 conversion that a fallible function executes -- in + * its own body or in any function it calls -- has no i32 value (overflow, or NaN). + * The out-parameter is NOT written (D17); calling again gives the same answer. */ #ifndef ML_ST_OVERFLOW #define ML_ST_OVERFLOW (-3) #endif @@ -1089,8 +1094,9 @@ int32_t mlx_line_check(int32_t /* qty */, int32_t* /* tier */, int32_t* out_valu #define ML_ST_INSUFFICIENT_BUFFER (-1) #endif -/* A fallible function's i32 arithmetic or f64-to-i32 conversion has no i32 value (overflow, - * or NaN). The out-parameter is NOT written (D17); calling again gives the same answer. */ +/* i32 arithmetic or an f64-to-i32 conversion that a fallible function executes -- in + * its own body or in any function it calls -- has no i32 value (overflow, or NaN). + * The out-parameter is NOT written (D17); calling again gives the same answer. */ #ifndef ML_ST_OVERFLOW #define ML_ST_OVERFLOW (-3) #endif @@ -1261,8 +1267,9 @@ int32_t mlx_safe_div(double /* a */, double /* b */, double* out_value); #ifndef ML_ST_INDEX_OUT_OF_RANGE #define ML_ST_INDEX_OUT_OF_RANGE (-2) #endif -/* A fallible function's i32 arithmetic or f64-to-i32 conversion has no i32 value (overflow, - * or NaN). The out-parameter is NOT written (D17); calling again gives the same answer. */ +/* i32 arithmetic or an f64-to-i32 conversion that a fallible function executes -- in + * its own body or in any function it calls -- has no i32 value (overflow, or NaN). + * The out-parameter is NOT written (D17); calling again gives the same answer. */ #ifndef ML_ST_OVERFLOW #define ML_ST_OVERFLOW (-3) #endif @@ -1324,8 +1331,9 @@ int32_t mlx_one_payment(int32_t /* principal */, int32_t /* months */, int32_t / #define ML_ST_INSUFFICIENT_BUFFER (-1) #endif -/* A fallible function's i32 arithmetic or f64-to-i32 conversion has no i32 value (overflow, - * or NaN). The out-parameter is NOT written (D17); calling again gives the same answer. */ +/* i32 arithmetic or an f64-to-i32 conversion that a fallible function executes -- in + * its own body or in any function it calls -- has no i32 value (overflow, or NaN). + * The out-parameter is NOT written (D17); calling again gives the same answer. */ #ifndef ML_ST_OVERFLOW #define ML_ST_OVERFLOW (-3) #endif diff --git a/compiler/tests/golden/units.pas.txt b/compiler/tests/golden/units.pas.txt index da5ee80..2248e9f 100644 --- a/compiler/tests/golden/units.pas.txt +++ b/compiler/tests/golden/units.pas.txt @@ -52,8 +52,9 @@ const { An index outside 0..len, or a span outside the string. The out-parameter is NOT written (D17), and the length is the one YOU passed alongside the pointer. } ML_ST_INDEX_OUT_OF_RANGE = -2; - { A fallible function's i32 arithmetic or f64-to-i32 conversion has no i32 value - (overflow, or NaN). The out-parameter is NOT written (D17). } + { i32 arithmetic or an f64-to-i32 conversion that a fallible function executes -- + in its own body or in any function it calls -- has no i32 value (overflow, or NaN). + The out-parameter is NOT written (D17). } ML_ST_OVERFLOW = -3; function ml_module_abi_version: LongWord; cdecl; external ML_MODULE; @@ -147,8 +148,9 @@ const { An index outside 0..len, or a span outside the string. The out-parameter is NOT written (D17), and the length is the one YOU passed alongside the pointer. } ML_ST_INDEX_OUT_OF_RANGE = -2; - { A fallible function's i32 arithmetic or f64-to-i32 conversion has no i32 value - (overflow, or NaN). The out-parameter is NOT written (D17). } + { i32 arithmetic or an f64-to-i32 conversion that a fallible function executes -- + in its own body or in any function it calls -- has no i32 value (overflow, or NaN). + The out-parameter is NOT written (D17). } ML_ST_OVERFLOW = -3; function ml_module_abi_version: LongWord; cdecl; external ML_MODULE; @@ -200,8 +202,9 @@ const { An index outside 0..len, or a span outside the string. The out-parameter is NOT written (D17), and the length is the one YOU passed alongside the pointer. } ML_ST_INDEX_OUT_OF_RANGE = -2; - { A fallible function's i32 arithmetic or f64-to-i32 conversion has no i32 value - (overflow, or NaN). The out-parameter is NOT written (D17). } + { i32 arithmetic or an f64-to-i32 conversion that a fallible function executes -- + in its own body or in any function it calls -- has no i32 value (overflow, or NaN). + The out-parameter is NOT written (D17). } ML_ST_OVERFLOW = -3; function ml_module_abi_version: LongWord; cdecl; external ML_MODULE; @@ -696,8 +699,9 @@ const { An index outside 0..len, or a span outside the string. The out-parameter is NOT written (D17), and the length is the one YOU passed alongside the pointer. } ML_ST_INDEX_OUT_OF_RANGE = -2; - { A fallible function's i32 arithmetic or f64-to-i32 conversion has no i32 value - (overflow, or NaN). The out-parameter is NOT written (D17). } + { i32 arithmetic or an f64-to-i32 conversion that a fallible function executes -- + in its own body or in any function it calls -- has no i32 value (overflow, or NaN). + The out-parameter is NOT written (D17). } ML_ST_OVERFLOW = -3; function ml_module_abi_version: LongWord; cdecl; external ML_MODULE; @@ -731,8 +735,9 @@ const ML_MODULE = 'pack.dll'; ML_PACK_IFACE_HASH: UInt64 = UInt64($DA0077B3A04515AB); ML_PACK_ERR_E_EMPTY_BOX = 1; - { A fallible function's i32 arithmetic or f64-to-i32 conversion has no i32 value - (overflow, or NaN). The out-parameter is NOT written (D17). } + { i32 arithmetic or an f64-to-i32 conversion that a fallible function executes -- + in its own body or in any function it calls -- has no i32 value (overflow, or NaN). + The out-parameter is NOT written (D17). } ML_ST_OVERFLOW = -3; function ml_module_abi_version: LongWord; cdecl; external ML_MODULE; @@ -825,8 +830,9 @@ const NUL included. Truncation is a FAILURE, not a short success - nothing is written, and ml_needed is the exact size to allocate, in the same unit as ml_cap. } ML_ST_INSUFFICIENT_BUFFER = -1; - { A fallible function's i32 arithmetic or f64-to-i32 conversion has no i32 value - (overflow, or NaN). The out-parameter is NOT written (D17). } + { i32 arithmetic or an f64-to-i32 conversion that a fallible function executes -- + in its own body or in any function it calls -- has no i32 value (overflow, or NaN). + The out-parameter is NOT written (D17). } ML_ST_OVERFLOW = -3; function ml_module_abi_version: LongWord; cdecl; external ML_MODULE; @@ -935,8 +941,9 @@ const { An index outside 0..len, or a span outside the string. The out-parameter is NOT written (D17), and the length is the one YOU passed alongside the pointer. } ML_ST_INDEX_OUT_OF_RANGE = -2; - { A fallible function's i32 arithmetic or f64-to-i32 conversion has no i32 value - (overflow, or NaN). The out-parameter is NOT written (D17). } + { i32 arithmetic or an f64-to-i32 conversion that a fallible function executes -- + in its own body or in any function it calls -- has no i32 value (overflow, or NaN). + The out-parameter is NOT written (D17). } ML_ST_OVERFLOW = -3; function ml_module_abi_version: LongWord; cdecl; external ML_MODULE; @@ -1000,8 +1007,9 @@ const NUL included. Truncation is a FAILURE, not a short success - nothing is written, and ml_needed is the exact size to allocate, in the same unit as ml_cap. } ML_ST_INSUFFICIENT_BUFFER = -1; - { A fallible function's i32 arithmetic or f64-to-i32 conversion has no i32 value - (overflow, or NaN). The out-parameter is NOT written (D17). } + { i32 arithmetic or an f64-to-i32 conversion that a fallible function executes -- + in its own body or in any function it calls -- has no i32 value (overflow, or NaN). + The out-parameter is NOT written (D17). } ML_ST_OVERFLOW = -3; function ml_module_abi_version: LongWord; cdecl; external ML_MODULE; diff --git a/compiler/tests/helper_check_propagation.rs b/compiler/tests/helper_check_propagation.rs new file mode 100644 index 0000000..f0c9f1d --- /dev/null +++ b/compiler/tests/helper_check_propagation.rs @@ -0,0 +1,146 @@ +//! Helper check propagation — what is decided without loading a module +//! (`SPEC-helper-check-propagation` acceptance E, DP-P6, DP-P8, DP-P9). +//! +//! The values live in `hosts/rust-oracle/tests/helper_check_propagation.rs`. What is here: which +//! modules declare `ML_ST_OVERFLOW` now that `-3` can come through a helper (one set, read by +//! codegen and both bindings), which bodies the generated Rust carries, and that the +//! fingerprint does not move. + +use mlc::header::{emit_c_header, emit_delphi_unit}; +use mlc::iface::fingerprint; +use mlc::{compile_to_ir, compile_to_rust}; + +fn declares(src: &str) -> (bool, bool) { + let ir = compile_to_ir(src).expect("compile"); + let h = emit_c_header(&ir, "m"); + let pas = emit_delphi_unit(&ir, "m"); + ( + h.contains("#define ML_ST_OVERFLOW (-3)"), + pas.contains("ML_ST_OVERFLOW = -3;"), + ) +} + +/// **Acceptance E: a module whose only way to `-3` is a helper declares it — and a module +/// whose helpers cannot overflow does not.** +/// +/// The prototype measured the first row wrong: the module returned `-3` and neither binding +/// named it, because the predicate looked only at the fallible function's own body. +#[test] +fn a_module_that_reaches_overflow_only_through_a_helper_declares_the_status() { + for (src, can) in [ + // The helper holds the only checked operation. + ( + "fn sq(x: i32) -> i32 { return x * x }\nexport fn f(x: i32) -> i32! { return sq(x) }", + true, + ), + // Two hops down. + ( + "fn h2(x: i32) -> i32 { return x * x }\nfn h1(x: i32) -> i32 { return h2(x) }\n\ + export fn f(x: i32) -> i32! { return h1(x) }", + true, + ), + // Through an EXPORTED infallible function (DP-P2). + ( + "export fn wmul(a: i32, b: i32) -> i32 { return a * b }\n\ + export fn f(a: i32, b: i32) -> i32! { return wmul(a, b) }", + true, + ), + // `f64 as i32` inside an f64 helper (DP-P3). + ( + "fn toi(x: f64) -> f64 { return (x as i32) as f64 }\nexport fn f(x: f64) -> f64! { return toi(x) }", + true, + ), + // The string and array shapes. + ( + "fn sq(x: i32) -> i32 { return x * x }\nexport fn f(x: i32) -> string! { return sq(x) as string }", + true, + ), + ( + "fn sq(x: i32) -> i32 { return x * x }\nexport fn f(x: i32) -> [i32]! {\n result 1\n result[0] = sq(x)\n}", + true, + ), + // A helper with no checked operation, under a fallible caller: nothing to declare. + // This is `examples/order.mls`'s shape (`known`). + ( + "fn known(s: i32) -> bool { return s == 1 }\n\ + export fn f(s: i32) -> i32! {\n if known(s) { return 1 }\n return 0\n}", + false, + ), + // A checked helper under an INFALLIBLE caller only: it wraps there, nothing to declare. + ( + "fn sq(x: i32) -> i32 { return x * x }\nexport fn f(x: i32) -> i32 { return sq(x) }", + false, + ), + // `%` only (DP-O5), through a helper. + ( + "fn r(a: i32, b: i32) -> i32 { return a % b }\nexport fn f(a: i32, b: i32) -> i32! { return r(a, b) }", + false, + ), + ] { + let (h, pas) = declares(src); + assert_eq!(h, pas, "the header and the unit disagree about {src:?}"); + assert_eq!(h, can, "{src:?} declares ML_ST_OVERFLOW: {h}, expected {can}"); + } +} + +/// **DP-P8: only bodies somebody calls are emitted.** +/// +/// A plain body that only fallible callers reached would be dead code once they call the +/// checked copy, and a copy that nothing calls is over-emission. Both are the defects that broke +/// builds under an ambient `RUSTFLAGS=-D warnings` in the prototype; `emit_robustness.rs` builds +/// that way, and this pins which bodies exist. +#[test] +fn only_called_bodies_are_emitted() { + let rust = |src: &str| compile_to_rust(src).expect("compile"); + + // Called only from a fallible body: the checked copy, and no plain body. + let only_bang = + rust("fn sq(x: i32) -> i32 { return x * x }\nexport fn f(x: i32) -> i32! { return sq(x) }"); + assert!(only_bang.contains("fn ml_ck_sq("), "{only_bang}"); + assert!(!only_bang.contains("fn ml_fn_sq("), "{only_bang}"); + + // Called from both kinds of body: both. + let both = rust( + "fn sq(x: i32) -> i32 { return x * x }\nexport fn f(x: i32) -> i32! { return sq(x) }\n\ + export fn g(x: i32) -> i32 { return sq(x) }", + ); + assert!( + both.contains("fn ml_ck_sq(") && both.contains("fn ml_fn_sq("), + "{both}" + ); + + // Nothing to check in the helper: no copy at all, and the fallible body calls the plain one. + let no_ops = rust( + "fn known(s: i32) -> bool { return s == 1 }\n\ + export fn f(s: i32) -> i32! {\n if known(s) { return 1 }\n return 0\n}", + ); + assert!(!no_ops.contains("ml_ck_"), "{no_ops}"); + + // An exported callee keeps its plain body for the adapter and gains a copy. + let export = rust( + "export fn wmul(a: i32, b: i32) -> i32 { return a * b }\n\ + export fn f(a: i32, b: i32) -> i32! { return wmul(a, b) }", + ); + assert!( + export.contains("fn ml_ck_wmul(") && export.contains("fn ml_fn_wmul("), + "{export}" + ); + + // A helper nobody calls is emitted exactly as before this slice (acceptance H). + let uncalled = + rust("fn sq(x: i32) -> i32 { return x * x }\nexport fn f(x: i32) -> i32! { return x }"); + assert!( + uncalled.contains("fn ml_fn_sq(") && !uncalled.contains("ml_ck_"), + "{uncalled}" + ); +} + +/// **DP-P9: the fingerprint does not see which reserved negatives a call path reaches.** +#[test] +fn propagating_the_check_does_not_move_the_fingerprint() { + let fp = |src: &str| fingerprint(&compile_to_ir(src).expect("compile")); + assert_eq!( + fp("fn sq(x: i32) -> i32 { return x }\nexport fn f(x: i32) -> i32! { return sq(x) }"), + fp("fn sq(x: i32) -> i32 { return x * x }\nexport fn f(x: i32) -> i32! { return sq(x) }"), + ); +} diff --git a/docs/HOST_ABI.md b/docs/HOST_ABI.md index 6c99dad..62394a4 100644 --- a/docs/HOST_ABI.md +++ b/docs/HOST_ABI.md @@ -53,10 +53,11 @@ 호스트가 들을 자리가 없다. **호스트 규칙: 받은 `double`을 쓰기 전에 유한한지 본다** (C는 `isfinite`, Delphi는 `Math.IsNan`/`IsInfinite`). 모듈 저자가 `!`와 `raw != raw`로 막을 수도 있지만 **그것은 저자의 선택이지 ABI의 보장이 아니다.** -- **`!` 함수의 i32 산술과 `f64 as i32`는 값이 없으면 `ML_ST_OVERFLOW`(`-3`)로 실패한다** - (`SPEC-checked-arithmetic`) — `+ - *`, 단항 `-`, `i32::MIN / -1`, 그리고 NaN이거나 절단 뒤 i32 - 밖인 변환. **무한 함수는 그대로 wrap·포화한다** — status 채널이 없다. 생성 `.h`·`.pas`는 그 - 상수를 **낼 수 있는 모듈에만** 선언한다. `-3`도 절단이 아니다(아래 Q12 표). +- **`!` 함수가 실행하는 i32 산술과 `f64 as i32`는 값이 없으면 `ML_ST_OVERFLOW`(`-3`)로 실패한다** — + 자기 본문이든, 그 함수가 부른 무한 함수 안이든(`SPEC-checked-arithmetic` · `SPEC-helper-check-propagation`). + `+ - *`, 단항 `-`, `i32::MIN / -1`, 그리고 NaN이거나 절단 뒤 i32 밖인 변환. **호스트가 무한 함수를 직접 + 부르면 그대로 wrap·포화한다** — status 채널이 없다. 생성 `.h`·`.pas`는 그 상수를 **낼 수 있는 모듈에만** + 선언하고, 헬퍼를 거쳐서만 낼 수 있는 모듈도 그렇다. `-3`도 절단이 아니다(아래 Q12 표). - 마샬링은 **스칼라와 문자열**: `f64`/`bool`/`i32`, 그리고 아래에 적은 범위의 `string`. (이 줄은 "스칼라만"이었다 — 바로 아래 두 줄이 문자열 입력과 반환을 구현됐다고 적는 동안 그대로 남아 있었다. 한 불릿이 자기 자신과 모순됐다.) 실패 가능 함수는 D17대로 diff --git a/docs/LANGUAGE.md b/docs/LANGUAGE.md index 4b7e498..dc90417 100644 --- a/docs/LANGUAGE.md +++ b/docs/LANGUAGE.md @@ -141,18 +141,22 @@ `i32::MIN % -1`은 참 나머지 `0`이 i32 안이라 어디서나 `0`이다(전부 측정). `%`는 i32 전용. **제수를 리터럴 `0`으로 쓰면**(`x / 0`) 컴파일 에러다 — 런타임 규약과 다른 층이고, 리터럴은 언제나 실수이기 때문이다 -- **`i32`의 `+`·`-`·`*`가 넘치면 — 연산자가 아니라 본문이 정한다**(SPEC-checked-arithmetic): - - **`!` 함수 본문에서는 `ML_ST_OVERFLOW`(`-3`)로 실패한다.** 다른 실패와 같이 호스트는 out을 - 읽지 않는다(D17 DP-E3). +- **`i32`의 `+`·`-`·`*`가 넘치면 — 연산자가 아니라 호출 경로가 정한다**(SPEC-checked-arithmetic · + SPEC-helper-check-propagation): + - **`!` 함수가 실행하는 산술은 `ML_ST_OVERFLOW`(`-3`)로 실패한다** — 자기 본문이든, 그 함수가 + (직접 또는 여러 단계로) 부른 무한 함수 안이든(export된 함수 포함). 다른 실패와 같이 호스트는 + out을 읽지 않는다(D17 DP-E3). `i32::MAX + 1`, `46341 * 46341`, `i32::MIN - 1`이 `-3`이고 경계 바로 안쪽은 정확한 값이다 (로드된 모듈에서 측정, `checked_arithmetic.rs`). 이 status를 낼 수 있는 모듈의 `.h`·`.pas`가 그 이름을 선언한다. `result` 뒤에 나면 버퍼는 `-2`와 같이 정의되지 않는다(`HOST_ABI.md`). - - **무한 함수 본문에서는 wrap한다** — `2000000000 + 2000000000 = -294967296`, + - **무한 함수를 무한 본문이나 호스트가 부르면 wrap한다** — `2000000000 + 2000000000 = -294967296`, `i32::MAX + 1 = i32::MIN`(측정, `i32_type.rs`). **포화가 아니다**: 같은 언어의 `f64 as i32`는 무한 함수에서 **포화한다**(아래 "명시적 변환"), 그래서 *"i32는 clamp한다"* 로 배운 저자가 산술에서 틀린다. **조용하다** — 무한 함수에는 호스트가 들을 채널이 없다(D17). - - **같은 `a * b`가 시그니처에 따라 다르게 동작한다.** `!` 함수가 부른 **무한 헬퍼** 안의 산술도 - wrap한다 — 의도한 wrap을 쓰는 방법이 그것이다(DP-O7). 리터럴만의 오버플로(`2147483647 + 1`)는 + - **같은 헬퍼가 부르는 쪽에 따라 다르게 동작한다.** `fn sq(x: i32) -> i32 { return x * x }`는 + `!` 함수가 부르면 `sq(46341)`이 `-3`, 무한 함수가 부르면 `-2147479015`다(측정, + `helper_check_propagation.rs`). 그 대신 식을 헬퍼로 빼도 답이 바뀌지 않는다. **의도한 wrap을 + 쓰는 탈출구는 없다** — 무한 헬퍼가 그 역할을 하던 규칙은 뒤집혔다(DP-P10). 리터럴만의 오버플로(`2147483647 + 1`)는 컴파일 에러가 아니라 **실행 시 `-3`**이다(DP-O9). 참값이 i32에 드는데 중간값만 넘쳐도 실패한다(`a * 2 / 2`가 `a = 2^30`에서 `-3`, 측정) — 정답을 주는 것은 `i64`뿐이고, 그것은 아래 공백 블록에 있다. diff --git a/docs/STATUS.md b/docs/STATUS.md index fb2069e..e71e7cf 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -235,9 +235,9 @@ > > **▶ 다음 세션 — 여기서 시작한다.** 순서대로: > -> 1. **헬퍼 체크 전파 SPEC 확인 — 사용자 결정.** 방향(§9-71.6의 F2)은 확인받았다(2026-09-26). 초안은 -> `docs/slices/SPEC-helper-check-propagation.md`이고 §4의 DP-P2~P11이 확인을 기다린다. 확인되면 §6의 두 PR로 -> 구현한다. 시험 구현은 로컬 브랜치 `exp/wide-local`에 있다(푸시 안 함). +> 1. **헬퍼 체크 전파 — 구현 중.** DP-P1~P11 전부 사용자 확인(2026-09-26). `docs/slices/SPEC-helper-check-propagation.md` +> §6의 작업 1(검사 집합 · 사본 · 바인딩 술어 · 테스트 · 문서)이 들어갔고, **작업 2가 남았다** — 예제에 헬퍼를 넣어 +> 실제 C 호스트가 `-3`을 받는 수용 F, §9-71의 626행 재측정, 슬라이스 닫기. > 2. **작은 항목**: C# 호스트(Phase 4) · LSP(Phase 3, 큰 일). > 3. **외부**: `X1`·`X2` — Delphi가 있는 CI 러너. 이 저장소 안에서는 닫히지 않는다. > diff --git a/docs/slices/README.md b/docs/slices/README.md index 08b517f..de787d5 100644 --- a/docs/slices/README.md +++ b/docs/slices/README.md @@ -63,7 +63,7 @@ TDD 구현 → Grok 검증 → PR** 순서를 따른다(`CLAUDE.md` "개발 방 | [나눗셈 가드가 왼쪽 피연산자를 건너뛴다](SPEC-division-guard-operands.md) | ✅ **구현 완료** (2026-09-23) | 제수가 0이면 `/`·`%`의 왼쪽 피연산자(배열 경계 검사)가 실행되지 않던 결함 — 왼쪽을 먼저 묶어 평가한다 | #287 / #291 | | [상수 선언 `const` · `export const`](SPEC-constants.md) | ✅ **구현 완료** (2026-09-26) | 이름 붙은 리터럴 — `export const`만 헤더(`ML__CONST_`)와 지문에 나가, 번호를 바꾼 모듈을 게이트가 거부한다 | #325 / #326·#327 | | [체크드 정수 산술](SPEC-checked-arithmetic.md) | ✅ **구현 완료** (2026-09-26) | `!` 본문의 i32 오버플로와 `f64 as i32`가 `ML_ST_OVERFLOW`(`-3`)로 실패한다 — 조용한 오답 161행 중 101이 status가 된다 | §9-69 / #334 · §9-70 | -| [헬퍼 체크 전파](SPEC-helper-check-propagation.md) | 🔨 **SPEC 초안 · 확인 대기** (2026-09-26) | `!` 본문이 부른 무한 헬퍼의 산술도 검사하고 `-3`을 전파한다 — 방향(F2)은 확인받았고 하위 결정 DP-P2~P11이 남았다 | §9-71 / — | +| [헬퍼 체크 전파](SPEC-helper-check-propagation.md) | 🔨 **확정 · 구현 중** (2026-09-26) | `!` 본문이 부른 무한 헬퍼의 산술도 검사하고 `-3`을 전파한다 — DP-P1~P11 사용자 확인, §6 작업 1이 들어갔고 작업 2(C 호스트 수용 · 재측정)가 남았다 | §9-71 / — | > **2026-09-25에 행을 한 줄로 접었다.** 접기 전의 서술은 [`../history/slices-index.md`](../history/slices-index.md)에 > 바이트 그대로 있다. **새 행도 한 줄로 쓴다** — 행당 400 B(`every_slice_index_row_is_one_line`)이고, 경위는 그 @@ -126,7 +126,7 @@ TDD 구현 → Grok 검증 → PR** 순서를 따른다(`CLAUDE.md` "개발 방 두 번째 호스트(C#, ROADMAP Phase 4), 그리고 ROADMAP Phase 2가 이름만 두고 설계가 없던 둘 — **인터페이스 정의 파일**(설계부터)과 **개발/배포 빌드 분리**(D13, `SECURITY.md` P1). *(2026-09-25 로드맵 검토에서 더했다 — Phase 2의 다섯 항목 중 이 둘이 이 목록에 없었다.)* -§7(§9-69, 12개 도메인 72규칙과 귀속 대조)이 더한 후보: **헬퍼 체크 전파**(방향을 확인받아 SPEC 초안이 있다, 위 색인)와 **넓은 중간값**(둘 다 626행으로 시험 구현했다 — 결정표는 §9-71.6), **i64**(§9-71: 옵트인이라 귀속 대조에서 51행이 빠졌다), **입력 문자열의 인코딩 계약**(닫힌 DP-S2의 재개 — `OPEN_QUESTIONS.md` Q17이 (A)로 열어 두었고 재검토 조건은 거기 있다), 비트 연산자, +§7(§9-69, 12개 도메인 72규칙과 귀속 대조)이 더한 후보: **헬퍼 체크 전파**(확정되어 구현 중이다, 위 색인)와 **넓은 중간값**(둘 다 626행으로 시험 구현했다 — 결정표는 §9-71.6), **i64**(§9-71: 옵트인이라 귀속 대조에서 51행이 빠졌다), **입력 문자열의 인코딩 계약**(닫힌 DP-S2의 재개 — `OPEN_QUESTIONS.md` Q17이 (A)로 열어 두었고 재검토 조건은 거기 있다), 비트 연산자, 지역 작업 배열, 수학 내장 함수, 문자 단위 길이. **struct는 §9-1이 강제되지 않는다고 결론냈다** — 고르려면 그 절을 먼저 읽는다(§9-69도 귀속 0으로 다시 확인했다). 배열도 같은 결론이었고(§9-9가 다섯 번째로), **입력은 그 사실을 SPEC 첫 절에 적은 채로 열렸다**. diff --git a/docs/slices/SPEC-helper-check-propagation.md b/docs/slices/SPEC-helper-check-propagation.md index 56b71e5..a7188b9 100644 --- a/docs/slices/SPEC-helper-check-propagation.md +++ b/docs/slices/SPEC-helper-check-propagation.md @@ -1,8 +1,8 @@ # SPEC — 헬퍼 체크 전파 (`!` 본문이 부른 헬퍼도 검사한다) -- **상태: 초안. 사용자 확인 전이며 제품 코드는 한 줄도 쓰지 않았다.** - - **방향은 확인받았다(2026-09-26)**: §9-71.6의 권고안 F2 — `!` 함수가 부른 무한 헬퍼 안의 산술도 검사한다. - 이 문서가 묻는 것은 **§4의 하위 결정**이다. +- **상태: 확정 · 구현 중.** §4의 DP-P1~P11 전부 **사용자 확인**(2026-09-26, 권고안 그대로). §6의 작업 1이 + 들어갔고 작업 2가 남았다. + - 방향은 먼저 확인받았다: §9-71.6의 권고안 F2 — `!` 함수가 부른 무한 헬퍼 안의 산술도 검사한다. - 선행: `SPEC-checked-arithmetic.md`(DP-O1 — `!` 본문만 · **DP-O7 — 무한 헬퍼가 탈출구**, 이 슬라이스가 뒤집는다 · DP-O4 — 지문·ABI 불변 · DP-O6 — `result` 뒤) · `SPEC-calls.md`(재귀 금지 — 호출 그래프는 DAG) - 관련 결정: **D17**(음수 status 예약) · #238(바인딩은 모듈이 낼 수 있는 status를 빠짐없이, 그리고 그것만 선언한다) @@ -132,8 +132,9 @@ - **이름은 `ml_ck_`**(DP-P7) — 사용자 함수는 언제나 `ml_fn_<이름>`이고 파라미터·지역 변수는 `ml_`로 시작할 수 없어, 어떤 합법한 소스와도 겹치지 않는다. 시그니처는 원래 함수의 파라미터에 `Result` 반환. -- **참조되는 본문만 방출한다**(DP-P8): `ml_fn_`는 무한 본문·export 어댑터가 부를 때, `ml_ck_`는 `!` 본문이나 다른 - 사본이 부를 때만. `#![allow(dead_code)]`로 덮지 않는다 — 덮으면 호출자 없는 사본(과다 방출)도 숨는다(Grok). +- **참조되는 본문만 방출한다**(DP-P8): `ml_ck_`는 `!` 본문이나 다른 사본이 부를 때만 있고, `ml_fn_`가 빠지는 경우는 + 하나뿐이다 — 검사 집합의 헬퍼를 `!` 본문과 사본만 부를 때(그 본문은 사본이 대신한다). 아무도 부르지 않는 함수는 + 이 슬라이스와 무관하게 오늘처럼 방출된다(§5). `#![allow(dead_code)]`로 덮지 않는다 — 덮으면 호출자 없는 사본(과다 방출)도 숨는다(Grok). 크레이트 머리의 `allow` 줄은 바꾸지 않는다. - 결과: 이 슬라이스가 닿지 않는 모듈의 생성 Rust는 **글자 그대로 같다**(예제 골든 불변 — §3 H). @@ -174,7 +175,9 @@ export를 탈출구로 남기는 안(§2.2의 반대)은 받지 않는다. --- -## 4. 설계 결정 (확인 요청) +## 4. 설계 결정 + +DP-P1은 방향 확인 때, DP-P2~P11은 이 표 그대로 **사용자 확인**을 받았다(2026-09-26). | DP | 결정 | 대안 | 근거 | |---|---|---|---| @@ -200,11 +203,11 @@ export를 탈출구로 남기는 안(§2.2의 반대)은 받지 않는다. - 생성 Rust 전체의 `-D warnings` 청결 — 이 슬라이스는 **자기가 만드는 경고**(쓰지 않는 사본·본문)만 막는다. 이미 있는 것은 그대로 두고 기록한다: 사용자 함수 이름이 snake_case가 아니면(`fn a__b`) 오늘도 주변 `RUSTFLAGS=-D warnings`에서 `non_snake_case`로 빌드가 깨진다(측정). 크레이트에 `allow`를 더하면 모든 모듈의 생성 Rust가 바뀌어 §3 H와 부딪히므로 - 따로 정한다. 다른 기존 경고(예: 쓰지 않는 문자열 헬퍼)도 같다. + 따로 정한다. 다른 기존 경고(예: 쓰지 않는 문자열 헬퍼, 아무도 부르지 않는 사용자 함수)도 같다. --- -## 6. 작업 분해 (확인 후) +## 6. 작업 분해 | 작업 | 수용 | 크기 | |---|---|---| diff --git a/hosts/rust-oracle/tests/checked_arithmetic.rs b/hosts/rust-oracle/tests/checked_arithmetic.rs index 113110a..913d3bb 100644 --- a/hosts/rust-oracle/tests/checked_arithmetic.rs +++ b/hosts/rust-oracle/tests/checked_arithmetic.rs @@ -164,7 +164,12 @@ fn string_and_array_bodies_are_checked_too() { drop(m); } -/// **Acceptance C, DP-O7, DP-O9: the rule belongs to the body.** +/// **Acceptance C, DP-O9 — and DP-O7 as `SPEC-helper-check-propagation` reversed it.** +/// +/// An infallible body with no status channel keeps wrapping. A helper is no longer an escape +/// hatch: under a fallible caller it is checked too, because the rule belongs to the call path +/// (DP-P1). The values of that rule live in `helper_check_propagation.rs`; this line keeps the +/// case this file used to pin, so the reversal is visible where the old rule was. #[test] fn infallible_bodies_still_wrap_and_literals_are_checked() { let (_out, m) = load("scope"); @@ -183,9 +188,11 @@ fn infallible_bodies_still_wrap_and_literals_are_checked() { assert_eq!(un(via_const, 0), (0, i32::MAX)); assert_eq!(un(via_const, 1), (OVERFLOW, -7)); - // DP-O7: an infallible helper is the escape hatch — it wraps even under a fallible caller. + // DP-O7 reversed (SPEC-helper-check-propagation DP-P1, DP-P10): the helper `twice` runs + // under a fallible caller, so its `a * 2` is checked and the answer is -3, not i32::MIN. let via_helper: Un = sym(&m, b"mlx_via_helper\0"); - assert_eq!(un(via_helper, 1073741824), (0, i32::MIN)); + assert_eq!(un(via_helper, 1073741823), (0, 2147483646)); + assert_eq!(un(via_helper, 1073741824), (OVERFLOW, -7)); drop(m); } diff --git a/hosts/rust-oracle/tests/helper_check_propagation.rs b/hosts/rust-oracle/tests/helper_check_propagation.rs new file mode 100644 index 0000000..b989ffa --- /dev/null +++ b/hosts/rust-oracle/tests/helper_check_propagation.rs @@ -0,0 +1,238 @@ +//! Helper check propagation — the values a loaded module returns +//! (`SPEC-helper-check-propagation` acceptance A, B, C, D and G(1)). +//! +//! The rule is the CALL PATH's: arithmetic a fallible body executes is checked wherever it is +//! written, so a helper without `!` fails with `ML_ST_OVERFLOW` when a `!` function calls it and +//! still wraps when an infallible function — or the host — calls it. Every case is measured on +//! both sides of its boundary, and every failing case beside the caller's own-arithmetic twin. +#![cfg(windows)] + +use ml_oracle::Module; +use mlc::emit::emit_artifacts; + +mod common; + +const OVERFLOW: i32 = mlc::abi::ML_ST_OVERFLOW; +const OUT_OF_RANGE: i32 = mlc::abi::ML_ST_INDEX_OUT_OF_RANGE; + +const SRC: &str = " +fn sq(x: i32) -> i32 { return x * x } +fn h3(x: i32) -> i32 { return x * x } +fn h2(x: i32) -> i32 { return h3(x) + 1 } +fn h1(x: i32) -> i32 { return h2(x) - 1 } +fn big(a: i32, b: i32) -> bool { return a * b > 100 } +fn toi(x: f64) -> f64 { return (x as i32) as f64 } +export fn wmul(a: i32, b: i32) -> i32 { return a * b } +fn viaint(a: i32, b: i32) -> i32 { return wmul(a, b) } +export fn scalar(x: i32) -> i32! { return sq(x) } +export fn plain_sq(x: i32) -> i32 { return sq(x) } +export fn own(x: i32) -> i32! { return x * x } +export fn text(x: i32) -> string! { return \"v=\" + sq(x) as string } +export fn arr(x: i32) -> [i32]! { + result 3 + result[0] = 7 + result[1] = sq(x) + result[2] = 9 +} +export fn chain(x: i32) -> i32! { return h1(x) } +export fn chain_plain(x: i32) -> i32 { return h1(x) } +export fn pred(a: i32, b: i32) -> bool! { return big(a, b) } +export fn conv(x: f64) -> f64! { return toi(x) } +export fn call_export(a: i32, b: i32) -> i32! { return wmul(a, b) } +export fn call_via(a: i32, b: i32) -> i32! { return viaint(a, b) } +export fn and_short(p: bool, x: i32) -> bool! { return p && sq(x) > 0 } +export fn left_index(xs: [i32], x: i32) -> i32! { return xs[5] + sq(x) } +export fn left_helper(xs: [i32], x: i32) -> i32! { return sq(x) + xs[5] } +"; + +type Un = extern "C" fn(i32, *mut i32) -> i32; +type Bin = extern "C" fn(i32, i32, *mut i32) -> i32; +type Plain = extern "C" fn(i32) -> i32; +type PlainBin = extern "C" fn(i32, i32) -> i32; +type Text = extern "C" fn(i32, *mut u8, i32, *mut i32) -> i32; +type Arr = extern "C" fn(i32, *mut i32, i32, *mut i32) -> i32; +type Pred = extern "C" fn(i32, i32, *mut bool) -> i32; +type Conv = extern "C" fn(f64, *mut f64) -> i32; +type And = extern "C" fn(bool, i32, *mut bool) -> i32; +type Idx = extern "C" fn(*const i32, i32, i32, *mut i32) -> i32; + +fn load(tag: &str) -> (common::TempOut, Module) { + load_src(tag, SRC) +} + +fn load_src(tag: &str, src: &str) -> (common::TempOut, Module) { + let out = common::TempOut::new(&format!("hcp_{tag}")); + let arts = emit_artifacts(src, "hcp", &out).expect("emit hcp"); + let m = Module::load(arts.dll.to_str().unwrap()).expect("load hcp.dll"); + (out, m) +} + +fn sym(m: &Module, name: &[u8]) -> T { + let p = m.symbol(name).unwrap(); + unsafe { std::mem::transmute_copy(&p) } +} + +/// Status and out value; the out value stays at the sentinel when the call fails (D17). +fn un(f: Un, x: i32) -> (i32, i32) { + let mut v = -7; + (f(x, &mut v), v) +} + +fn bin(f: Bin, a: i32, b: i32) -> (i32, i32) { + let mut v = -7; + (f(a, b, &mut v), v) +} + +/// **Acceptance A: all three return shapes, beside the caller's own arithmetic.** +/// +/// 46340² fits in i32 and 46341² does not. The own-arithmetic function `own` is the control: +/// a helper call has to answer exactly what the same expression written inline answers. +#[test] +fn a_helper_under_a_fallible_caller_fails_like_its_own_arithmetic() { + let (_out, m) = load("shapes"); + let scalar: Un = sym(&m, b"mlx_scalar\0"); + let own: Un = sym(&m, b"mlx_own\0"); + assert_eq!(un(scalar, 46340), (0, 2147395600)); + assert_eq!(un(scalar, 46341), (OVERFLOW, -7)); + assert_eq!(un(own, 46341), (OVERFLOW, -7), "the control"); + + let text: Text = sym(&m, b"mlx_text\0"); + let mut buf = [0u8; 32]; + let mut needed = -7; + assert_eq!(text(46340, buf.as_mut_ptr(), 32, &mut needed), 0); + assert_eq!(&buf[..needed as usize - 1], b"v=2147395600"); + let mut needed = -7; + assert_eq!(text(46341, buf.as_mut_ptr(), 32, &mut needed), OVERFLOW); + assert_eq!( + needed, -7, + "the helper fails before either Q12 pass writes anything" + ); + + // After `result 3`: DP-O6 — the status is -3 and the buffer is undefined, exactly as for + // the caller's own arithmetic (SPEC §2.4). No new rejection: this program compiles. + let arr: Arr = sym(&m, b"mlx_arr\0"); + let mut out = [0i32; 3]; + let mut n = -7; + assert_eq!(arr(3, out.as_mut_ptr(), 3, &mut n), 0); + assert_eq!(out, [7, 9, 9]); + assert_eq!(arr(46341, out.as_mut_ptr(), 3, &mut n), OVERFLOW); + drop(m); +} + +/// **Acceptance A and B: a chain three deep, and the same chain under an infallible caller.** +#[test] +fn a_chain_is_checked_under_a_fallible_caller_and_wraps_under_an_infallible_one() { + let (_out, m) = load("chain"); + let chain: Un = sym(&m, b"mlx_chain\0"); + assert_eq!(un(chain, 46340), (0, 2147395600)); + assert_eq!( + un(chain, 46341), + (OVERFLOW, -7), + "h1 -> h2 -> h3 overflows in h3" + ); + + // B: the same helpers called from a body with no status channel keep today's wrap. + // 46341² wraps to -2147479015; +1 then -1 leave it there. + let chain_plain: Plain = sym(&m, b"mlx_chain_plain\0"); + assert_eq!(chain_plain(46341), -2147479015); + // The one-hop case LANGUAGE.md quotes: `sq(46341)` is -3 under `scalar` (above) and this + // under an infallible caller — one helper, two answers, by caller. + let plain_sq: Plain = sym(&m, b"mlx_plain_sq\0"); + assert_eq!(plain_sq(46341), -2147479015); + drop(m); +} + +/// **Acceptance A (DP-P3): the return type does not matter — `bool` and `f64` helpers too.** +#[test] +fn bool_and_f64_helpers_are_checked() { + let (_out, m) = load("types"); + let pred: Pred = sym(&m, b"mlx_pred\0"); + let p = |a, b| { + let mut v = false; + (pred(a, b, &mut v), v) + }; + assert_eq!(p(10, 11), (0, true)); + assert_eq!(p(10, 10), (0, false)); + assert_eq!(p(65536, 65536).0, OVERFLOW, "a * b in a bool helper"); + + let conv: Conv = sym(&m, b"mlx_conv\0"); + let c = |x: f64| { + let mut v = -7.0; + (conv(x, &mut v), v) + }; + assert_eq!(c(3.9), (0, 3.0)); + assert_eq!(c(f64::NAN).0, OVERFLOW, "f64 as i32 in an f64 helper"); + assert_eq!(c(3e9).0, OVERFLOW); + drop(m); +} + +/// **Acceptance C (DP-P2): an exported infallible function is checked when a `!` body calls +/// it — directly or through an internal helper — and wraps when the host calls it.** +#[test] +fn an_exported_callee_is_checked_internally_and_wraps_for_the_host() { + let (_out, m) = load("export"); + let call_export: Bin = sym(&m, b"mlx_call_export\0"); + let call_via: Bin = sym(&m, b"mlx_call_via\0"); + let wmul: PlainBin = sym(&m, b"mlx_wmul\0"); + assert_eq!(bin(call_export, 46340, 46340), (0, 2147395600)); + assert_eq!(bin(call_export, 65536, 65536), (OVERFLOW, -7)); + assert_eq!( + bin(call_via, 65536, 65536), + (OVERFLOW, -7), + "the check does not stop at an export beneath an internal helper" + ); + assert_eq!(wmul(65536, 65536), 0, "the host's own call keeps wrapping"); + drop(m); +} + +/// **Acceptance D (DP-P4): a helper call is an operand like any other.** +/// +/// `&&` does not evaluate its right side when the left is false, and of two failing operands +/// the left one answers — an index `-2` or a helper `-3`, whichever comes first. +#[test] +fn a_helper_call_short_circuits_and_fails_in_source_order() { + let (_out, m) = load("order"); + let and_short: And = sym(&m, b"mlx_and_short\0"); + let a = |p, x| { + let mut v = true; + (and_short(p, x, &mut v), v) + }; + assert_eq!(a(false, 46341), (0, false), "the helper never ran"); + assert_eq!(a(true, 46341).0, OVERFLOW); + + let xs = [1i32, 2]; + let left_index: Idx = sym(&m, b"mlx_left_index\0"); + let left_helper: Idx = sym(&m, b"mlx_left_helper\0"); + let mut v = -7; + assert_eq!(left_index(xs.as_ptr(), 2, 46341, &mut v), OUT_OF_RANGE); + assert_eq!(left_helper(xs.as_ptr(), 2, 46341, &mut v), OVERFLOW); + drop(m); +} + +/// **Acceptance G(1) (DP-P7): the checked copies' names collide with no legal program.** +/// +/// A helper `mul`, a user function literally named `ck_mul` and one named `__twin_mul` (the +/// prototype's scheme, which failed with E0428 here) live in one module, and every one of them +/// is checked under the `!` caller. +#[test] +fn checked_copy_names_do_not_collide_with_user_names() { + let src = " +fn mul(a: i32) -> i32 { return a * 2 } +fn ck_mul(a: i32) -> i32 { return a + 1 } +fn __twin_mul(a: i32) -> i32 { return a * 3 } +export fn f(a: i32) -> i32! { return mul(a) - ck_mul(a) + __twin_mul(a) } +"; + let (_out, m) = load_src("names", src); + let f: Un = sym(&m, b"mlx_f\0"); + // 20 - 11 + 30: each name reached its own function. Had `ck_mul` resolved to mul's + // copy the answer would be 30, had `__twin_mul` done so, 29. + assert_eq!(un(f, 10), (0, 39)); + assert_eq!(un(f, 1073741824), (OVERFLOW, -7), "mul overflows"); + // 2a and a + 1 fit; 3a does not, and `__twin_mul(a)` runs before the outer `+`. + assert_eq!( + un(f, 715827883), + (OVERFLOW, -7), + "__twin_mul overflows, mul does not" + ); + drop(m); +} From 624f7b64f4fe63d9cc106f76a861f32532d84f70 Mon Sep 17 00:00:00 2001 From: xzawed <47019855+xzawed@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:11:29 +0900 Subject: [PATCH 2/3] test: the name-collision case keeps the plain bodies alive, so a colliding scheme can fail it Planting copy names inside the user namespace (ml_fn_ck_) passed the value test: every function there was called only from a fallible body, so no plain body was emitted to collide with. An infallible caller keeps them. Co-Authored-By: Claude Opus 5.5 (1M context) --- hosts/rust-oracle/tests/helper_check_propagation.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/hosts/rust-oracle/tests/helper_check_propagation.rs b/hosts/rust-oracle/tests/helper_check_propagation.rs index b989ffa..1388016 100644 --- a/hosts/rust-oracle/tests/helper_check_propagation.rs +++ b/hosts/rust-oracle/tests/helper_check_propagation.rs @@ -221,8 +221,14 @@ fn mul(a: i32) -> i32 { return a * 2 } fn ck_mul(a: i32) -> i32 { return a + 1 } fn __twin_mul(a: i32) -> i32 { return a * 3 } export fn f(a: i32) -> i32! { return mul(a) - ck_mul(a) + __twin_mul(a) } +export fn g(a: i32) -> i32 { return mul(a) - ck_mul(a) + __twin_mul(a) } "; + // `g` is the infallible caller that keeps every PLAIN body alive. Without it all three + // plain bodies are dropped (DP-P8) and there is nothing left for a copy's name to collide + // with — a planted `ml_fn_ck_` scheme passed this test until `g` was added. let (_out, m) = load_src("names", src); + let g: Plain = sym(&m, b"mlx_g\0"); + assert_eq!(g(10), 39); let f: Un = sym(&m, b"mlx_f\0"); // 20 - 11 + 30: each name reached its own function. Had `ck_mul` resolved to mul's // copy the answer would be 30, had `__twin_mul` done so, 29. From b3fbf6444ec8b68f37a048b1fe009c5eea8d1344 Mon Sep 17 00:00:00 2001 From: xzawed <47019855+xzawed@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:36:30 +0900 Subject: [PATCH 3/3] test: a helper under an infallible caller keeps saturating, measured where HOST_ABI says it Grok verify: the host-call saturation sentence had no helper-side assertion. Co-Authored-By: Claude Opus 5.5 (1M context) --- hosts/rust-oracle/tests/helper_check_propagation.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/hosts/rust-oracle/tests/helper_check_propagation.rs b/hosts/rust-oracle/tests/helper_check_propagation.rs index 1388016..8b73fae 100644 --- a/hosts/rust-oracle/tests/helper_check_propagation.rs +++ b/hosts/rust-oracle/tests/helper_check_propagation.rs @@ -38,6 +38,7 @@ export fn chain(x: i32) -> i32! { return h1(x) } export fn chain_plain(x: i32) -> i32 { return h1(x) } export fn pred(a: i32, b: i32) -> bool! { return big(a, b) } export fn conv(x: f64) -> f64! { return toi(x) } +export fn conv_plain(x: f64) -> f64 { return toi(x) } export fn call_export(a: i32, b: i32) -> i32! { return wmul(a, b) } export fn call_via(a: i32, b: i32) -> i32! { return viaint(a, b) } export fn and_short(p: bool, x: i32) -> bool! { return p && sq(x) > 0 } @@ -163,6 +164,12 @@ fn bool_and_f64_helpers_are_checked() { assert_eq!(c(3.9), (0, 3.0)); assert_eq!(c(f64::NAN).0, OVERFLOW, "f64 as i32 in an f64 helper"); assert_eq!(c(3e9).0, OVERFLOW); + + // B for the conversion: the same helper under an infallible caller keeps saturating + // (NaN -> 0, 3e9 -> i32::MAX), which is what HOST_ABI.md says a host call does. + let conv_plain: extern "C" fn(f64) -> f64 = sym(&m, b"mlx_conv_plain\0"); + assert_eq!(conv_plain(f64::NAN), 0.0); + assert_eq!(conv_plain(3e9), 2147483647.0); drop(m); }