From db5852e277d45777f1fa072a3fc877065b32d138 Mon Sep 17 00:00:00 2001 From: Daniel Morris Date: Wed, 20 May 2026 19:29:46 +0100 Subject: [PATCH 01/10] deps: add sha2, hmac, base64, hex, subtle for crypto primitives --- Cargo.lock | 92 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 5 +++ 2 files changed, 97 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index c4974f25..acf36163 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -128,6 +128,15 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -237,6 +246,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cranelift-bforest" version = "0.116.1" @@ -393,6 +411,16 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "deadpool" version = "0.12.3" @@ -411,6 +439,17 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -583,6 +622,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -673,6 +722,21 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "1.4.0" @@ -911,6 +975,7 @@ dependencies = [ name = "ilo" version = "0.12.0" dependencies = [ + "base64", "chrono", "clap", "cranelift-codegen", @@ -920,6 +985,8 @@ dependencies = [ "cranelift-native", "cranelift-object", "fastrand", + "hex", + "hmac", "inkwell", "libc", "logos", @@ -929,6 +996,8 @@ dependencies = [ "reqwest", "serde", "serde_json", + "sha2", + "subtle", "target-lexicon 0.12.16", "tempfile", "thiserror 2.0.18", @@ -1599,6 +1668,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1896,6 +1976,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1932,6 +2018,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "want" version = "0.3.1" diff --git a/Cargo.toml b/Cargo.toml index ec3dd3fd..7f7febc8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,11 @@ clap = { version = "4", features = ["derive"] } fastrand = "2" regex = "1" chrono = { version = "0.4", default-features = false, features = ["clock"] } +sha2 = "0.10" +hmac = "0.12" +base64 = "0.22" +hex = "0.4" +subtle = "2" [dev-dependencies] wiremock = "0.6" From b5be354ed7bb66320706a48e16d62ba78e75910e Mon Sep 17 00:00:00 2001 From: Daniel Morris Date: Wed, 20 May 2026 19:29:56 +0100 Subject: [PATCH 02/10] feat: register crypto builtins - Builtin enum, arity, tree-bridge, verifier Add 9 variants: Sha256, HmacSha256, Base64Enc, Base64Dec, Base64UrlEnc, Base64UrlDec, HexEnc, HexDec, CtEq. All appended to Builtin::ALL to preserve on-wire tags. Mark each as tree-bridge eligible in vm/mod.rs; base64-dec, base64url-dec, hex-dec added to tree_bridge_returns_result. Add arity entries to verify.rs BUILTINS table. --- src/builtins.rs | 58 +++++++++++++++++++++++++++++++++++++++++++++++++ src/verify.rs | 11 ++++++++++ src/vm/mod.rs | 15 +++++++++++++ 3 files changed, 84 insertions(+) diff --git a/src/builtins.rs b/src/builtins.rs index ba8f9ca2..593a61ac 100644 --- a/src/builtins.rs +++ b/src/builtins.rs @@ -226,6 +226,34 @@ pub enum Builtin { // Mirror of `??` for Result: `?? v d` is nil-coalesce for `O T`; this is // the Result equivalent. Tree-bridge eligible (2-arg, pure, no FnRef). DefaultOnErr, + + // Crypto primitives (0.12.1). Pure text-or-bytes-via-text ops; all + // tree-bridge eligible so VM and Cranelift inherit for free. + // + // `sha256 s > t` — SHA-256 hex digest (lowercase) of the UTF-8 bytes of `s`. + Sha256, + // `hmac-sha256 key body > t` — HMAC-SHA256 of `body` under `key`; returns + // lowercase hex. RFC 4231 vectors match. + HmacSha256, + // `base64-enc s > t` — standard base64 (RFC 4648 §4, with padding). + Base64Enc, + // `base64-dec s > R t t` — decode standard base64; Err on invalid input. + Base64Dec, + // `base64url-enc s > t` — base64url (RFC 4648 §5, no padding). Suitable + // for JWT header/payload segments. + Base64UrlEnc, + // `base64url-dec s > R t t` — decode base64url; Err on invalid input. + Base64UrlDec, + // `hex-enc bytes > t` — encode a list of integers 0-255 as lowercase hex. + HexEnc, + // `hex-dec s > R (L n) t` — decode a hex string to a list of byte values + // (each in 0-255). Err on odd-length or non-hex input. + HexDec, + // `ct-eq a b > b` — constant-time text equality. Returns bool without + // short-circuiting on mismatch, resisting timing side-channels. Use when + // comparing secrets (HMAC digests, tokens). Returns false for unequal + // lengths without leaking which is longer. + CtEq, } impl Builtin { @@ -388,6 +416,15 @@ impl Builtin { "dur-parse" => Some(Builtin::DurParse), "dur-fmt" => Some(Builtin::DurFmt), "default-on-err" => Some(Builtin::DefaultOnErr), + "sha256" => Some(Builtin::Sha256), + "hmac-sha256" => Some(Builtin::HmacSha256), + "base64-enc" => Some(Builtin::Base64Enc), + "base64-dec" => Some(Builtin::Base64Dec), + "base64url-enc" => Some(Builtin::Base64UrlEnc), + "base64url-dec" => Some(Builtin::Base64UrlDec), + "hex-enc" => Some(Builtin::HexEnc), + "hex-dec" => Some(Builtin::HexDec), + "ct-eq" => Some(Builtin::CtEq), _ => None, } } @@ -547,6 +584,15 @@ impl Builtin { Builtin::DurParse => "dur-parse", Builtin::DurFmt => "dur-fmt", Builtin::DefaultOnErr => "default-on-err", + Builtin::Sha256 => "sha256", + Builtin::HmacSha256 => "hmac-sha256", + Builtin::Base64Enc => "base64-enc", + Builtin::Base64Dec => "base64-dec", + Builtin::Base64UrlEnc => "base64url-enc", + Builtin::Base64UrlDec => "base64url-dec", + Builtin::HexEnc => "hex-enc", + Builtin::HexDec => "hex-dec", + Builtin::CtEq => "ct-eq", } } @@ -780,6 +826,18 @@ impl Builtin { // rounded up (ceil(ms / 1000)) so 1 ms => 1 s, 1001 ms => 2 s. Builtin::GetTo, Builtin::PstTo, + // Crypto primitives (0.12.1). Appended last to preserve every existing + // on-wire tag. All are tree-bridge eligible - pure text-or-bytes-via-text, + // no FnRef args, no I/O. See is_tree_bridge_eligible in src/vm/mod.rs. + Builtin::Sha256, + Builtin::HmacSha256, + Builtin::Base64Enc, + Builtin::Base64Dec, + Builtin::Base64UrlEnc, + Builtin::Base64UrlDec, + Builtin::HexEnc, + Builtin::HexDec, + Builtin::CtEq, ]; /// On-wire 8-bit tag for cross-engine builtin dispatch. See `ALL`. diff --git a/src/verify.rs b/src/verify.rs index e94e1f27..ac5581ac 100644 --- a/src/verify.rs +++ b/src/verify.rs @@ -487,6 +487,17 @@ const BUILTINS: &[(&str, &[&str], &str)] = &[ // type check (ok-type vs default must match); this entry feeds arity + // suggestion paths. ("default-on-err", &["R any t", "any"], "any"), + // Crypto primitives (0.12.1). All tree-bridge eligible — pure text/bytes + // ops, no FnRef args, no I/O. + ("sha256", &["t"], "t"), + ("hmac-sha256", &["t", "t"], "t"), + ("base64-enc", &["t"], "t"), + ("base64-dec", &["t"], "R t t"), + ("base64url-enc", &["t"], "t"), + ("base64url-dec", &["t"], "R t t"), + ("hex-enc", &["L n"], "t"), + ("hex-dec", &["t"], "R (L n) t"), + ("ct-eq", &["t", "t"], "b"), ]; fn builtin_arity(name: &str) -> Option { diff --git a/src/vm/mod.rs b/src/vm/mod.rs index 2b72dea8..d53f9b8d 100644 --- a/src/vm/mod.rs +++ b/src/vm/mod.rs @@ -704,6 +704,18 @@ pub(crate) fn is_tree_bridge_eligible(b: crate::builtins::Builtin, argc: usize) // JIT/AOT pick them up at zero opcode cost. (Builtin::GetTo, 2) => true, (Builtin::PstTo, 3) => true, + // Crypto primitives (0.12.1). All pure: no FnRef args, no I/O. + // Tree-bridge gives VM + Cranelift cross-engine parity at zero opcode + // cost - these are not hot-path (called once per request, not in loops). + (Builtin::Sha256, 1) => true, + (Builtin::HmacSha256, 2) => true, + (Builtin::Base64Enc, 1) => true, + (Builtin::Base64Dec, 1) => true, + (Builtin::Base64UrlEnc, 1) => true, + (Builtin::Base64UrlDec, 1) => true, + (Builtin::HexEnc, 1) => true, + (Builtin::HexDec, 1) => true, + (Builtin::CtEq, 2) => true, _ => false, } } @@ -732,6 +744,9 @@ pub(crate) fn tree_bridge_returns_result(b: crate::builtins::Builtin) -> bool { | Builtin::DurParse | Builtin::GetTo | Builtin::PstTo + | Builtin::Base64Dec + | Builtin::Base64UrlDec + | Builtin::HexDec ) } From 2d140b8b2aa4026a889c275a43e2ef6d7b4189a5 Mon Sep 17 00:00:00 2001 From: Daniel Morris Date: Wed, 20 May 2026 19:30:10 +0100 Subject: [PATCH 03/10] feat: implement crypto builtin evaluation in tree interpreter sha256: SHA-256 hex digest of UTF-8 bytes. hmac-sha256: HMAC-SHA256 lowercase hex (key, body both text). base64-enc/dec: RFC 4648 standard with padding. base64url-enc/dec: RFC 4648 url-safe no-pad. hex-enc: list of 0-255 integers to lowercase hex. hex-dec: hex string to list of byte values, accepts upper/lowercase. ct-eq: constant-time text equality via subtle::ConstantTimeEq. Decode ops return R t/L n t. --- src/interpreter/mod.rs | 221 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 221 insertions(+) diff --git a/src/interpreter/mod.rs b/src/interpreter/mod.rs index ad989856..81cf02e0 100644 --- a/src/interpreter/mod.rs +++ b/src/interpreter/mod.rs @@ -3793,6 +3793,227 @@ fn call_function(env: &mut Env, name: &str, args: Vec) -> Result { .unwrap_or(false); return Ok(Value::Bool(is)); } + // ----------------------------------------------------------------------- + // Crypto primitives (0.12.1) + // ----------------------------------------------------------------------- + if builtin == Some(Builtin::Sha256) && args.len() == 1 { + // sha256 s > t — SHA-256 hex digest (lowercase) of the UTF-8 bytes of s. + use sha2::{Digest, Sha256}; + let s = match &args[0] { + Value::Text(s) => s.clone(), + other => { + return Err(RuntimeError::new( + "ILO-R009", + format!("sha256 requires text, got {:?}", other), + )); + } + }; + let mut hasher = Sha256::new(); + hasher.update(s.as_bytes()); + let digest = hasher.finalize(); + return Ok(Value::Text(Arc::new(hex::encode(digest)))); + } + if builtin == Some(Builtin::HmacSha256) && args.len() == 2 { + // hmac-sha256 key body > t — HMAC-SHA256 of body under key, lowercase hex. + use hmac::{Hmac, Mac}; + use sha2::Sha256; + let key = match &args[0] { + Value::Text(s) => s.clone(), + other => { + return Err(RuntimeError::new( + "ILO-R009", + format!("hmac-sha256: key must be text, got {:?}", other), + )); + } + }; + let body = match &args[1] { + Value::Text(s) => s.clone(), + other => { + return Err(RuntimeError::new( + "ILO-R009", + format!("hmac-sha256: body must be text, got {:?}", other), + )); + } + }; + let mut mac = + Hmac::::new_from_slice(key.as_bytes()).expect("HMAC accepts any key length"); + mac.update(body.as_bytes()); + let result = mac.finalize(); + return Ok(Value::Text(Arc::new(hex::encode(result.into_bytes())))); + } + if builtin == Some(Builtin::Base64Enc) && args.len() == 1 { + // base64-enc s > t — standard base64 (RFC 4648 §4, with padding). + use base64::Engine as _; + let s = match &args[0] { + Value::Text(s) => s.clone(), + other => { + return Err(RuntimeError::new( + "ILO-R009", + format!("base64-enc requires text, got {:?}", other), + )); + } + }; + let encoded = base64::engine::general_purpose::STANDARD.encode(s.as_bytes()); + return Ok(Value::Text(Arc::new(encoded))); + } + if builtin == Some(Builtin::Base64Dec) && args.len() == 1 { + // base64-dec s > R t t — decode standard base64; Err on invalid input. + use base64::Engine as _; + let s = match &args[0] { + Value::Text(s) => s.clone(), + other => { + return Err(RuntimeError::new( + "ILO-R009", + format!("base64-dec requires text, got {:?}", other), + )); + } + }; + return match base64::engine::general_purpose::STANDARD.decode(s.as_bytes()) { + Ok(bytes) => match String::from_utf8(bytes) { + Ok(decoded) => Ok(Value::Ok(Box::new(Value::Text(Arc::new(decoded))))), + Err(e) => Ok(Value::Err(Box::new(Value::Text(Arc::new(format!( + "base64-dec: decoded bytes are not valid UTF-8: {}", + e + )))))), + }, + Err(e) => Ok(Value::Err(Box::new(Value::Text(Arc::new(format!( + "base64-dec: {}", + e + )))))), + }; + } + if builtin == Some(Builtin::Base64UrlEnc) && args.len() == 1 { + // base64url-enc s > t — base64url (RFC 4648 §5, no padding). + use base64::Engine as _; + let s = match &args[0] { + Value::Text(s) => s.clone(), + other => { + return Err(RuntimeError::new( + "ILO-R009", + format!("base64url-enc requires text, got {:?}", other), + )); + } + }; + let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(s.as_bytes()); + return Ok(Value::Text(Arc::new(encoded))); + } + if builtin == Some(Builtin::Base64UrlDec) && args.len() == 1 { + // base64url-dec s > R t t — decode base64url; Err on invalid input. + use base64::Engine as _; + let s = match &args[0] { + Value::Text(s) => s.clone(), + other => { + return Err(RuntimeError::new( + "ILO-R009", + format!("base64url-dec requires text, got {:?}", other), + )); + } + }; + return match base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(s.as_bytes()) { + Ok(bytes) => match String::from_utf8(bytes) { + Ok(decoded) => Ok(Value::Ok(Box::new(Value::Text(Arc::new(decoded))))), + Err(e) => Ok(Value::Err(Box::new(Value::Text(Arc::new(format!( + "base64url-dec: decoded bytes are not valid UTF-8: {}", + e + )))))), + }, + Err(e) => Ok(Value::Err(Box::new(Value::Text(Arc::new(format!( + "base64url-dec: {}", + e + )))))), + }; + } + if builtin == Some(Builtin::HexEnc) && args.len() == 1 { + // hex-enc bytes > t — encode a list of integers 0-255 as lowercase hex. + let list = match &args[0] { + Value::List(xs) => xs.clone(), + other => { + return Err(RuntimeError::new( + "ILO-R009", + format!("hex-enc requires a list of numbers (0-255), got {:?}", other), + )); + } + }; + let mut byte_buf: Vec = Vec::with_capacity(list.len()); + for (i, v) in list.iter().enumerate() { + match v { + Value::Number(n) => { + let b = *n as i64; + if !(0..=255).contains(&b) || n.fract() != 0.0 { + return Err(RuntimeError::new( + "ILO-R009", + format!( + "hex-enc: element {} ({}) is not an integer in 0-255", + i, n + ), + )); + } + byte_buf.push(b as u8); + } + other => { + return Err(RuntimeError::new( + "ILO-R009", + format!( + "hex-enc: element {} must be a number, got {:?}", + i, other + ), + )); + } + } + } + return Ok(Value::Text(Arc::new(hex::encode(byte_buf)))); + } + if builtin == Some(Builtin::HexDec) && args.len() == 1 { + // hex-dec s > R (L n) t — decode hex string to list of byte values (0-255). + let s = match &args[0] { + Value::Text(s) => s.clone(), + other => { + return Err(RuntimeError::new( + "ILO-R009", + format!("hex-dec requires text, got {:?}", other), + )); + } + }; + return match hex::decode(s.as_str()) { + Ok(bytes) => { + let list: Vec = bytes.iter().map(|b| Value::Number(*b as f64)).collect(); + Ok(Value::Ok(Box::new(Value::List(Arc::new(list))))) + } + Err(e) => Ok(Value::Err(Box::new(Value::Text(Arc::new(format!( + "hex-dec: {}", + e + )))))), + }; + } + if builtin == Some(Builtin::CtEq) && args.len() == 2 { + // ct-eq a b > b — constant-time text equality. Resists timing attacks. + use subtle::ConstantTimeEq; + let a = match &args[0] { + Value::Text(s) => s.clone(), + other => { + return Err(RuntimeError::new( + "ILO-R009", + format!("ct-eq: first argument must be text, got {:?}", other), + )); + } + }; + let b = match &args[1] { + Value::Text(s) => s.clone(), + other => { + return Err(RuntimeError::new( + "ILO-R009", + format!("ct-eq: second argument must be text, got {:?}", other), + )); + } + }; + // Compare byte-by-byte in constant time. Lengths are compared first + // without branching on the result (subtle's contract). + let eq: bool = a.as_bytes().ct_eq(b.as_bytes()).into(); + return Ok(Value::Bool(eq)); + } + // ----------------------------------------------------------------------- + // End crypto primitives + // ----------------------------------------------------------------------- if builtin == Some(Builtin::Rd) && (args.len() == 1 || args.len() == 2) { let path = match &args[0] { Value::Text(s) => s.clone(), From 23fd33d13b0a959f7b1481a712064b2964e655e6 Mon Sep 17 00:00:00 2001 From: Daniel Morris Date: Wed, 20 May 2026 19:30:30 +0100 Subject: [PATCH 04/10] docs+tests: cross-engine regression tests, example, doc sync for crypto builtins regression_crypto_primitives.rs: 36 cross-engine tests covering sha256 (empty/abc/hello-world vectors), hmac-sha256 (RFC 4231 test case 2 + simple vector), base64 round-trips and padding, base64url no-padding and URL-safe alphabet, hex encode/decode round-trips, ct-eq equal/ unequal/different-length/empty, and the HMAC comparison use-case. examples/crypto-primitives.ilo: annotated example with run/out assertions exercising all 9 builtins across tree/VM. Docs: SPEC.md builtin table + Crypto section, ai.txt entries after default-on-err, ilo-builtins-text.md Crypto section, CHANGELOG.md. --- CHANGELOG.md | 1 + SPEC.md | 47 +++ examples/crypto-primitives.ilo | 67 +++++ skills/ilo/ilo-builtins-text.md | 30 +- tests/regression_crypto_primitives.rs | 408 ++++++++++++++++++++++++++ 5 files changed, 552 insertions(+), 1 deletion(-) create mode 100644 examples/crypto-primitives.ilo create mode 100644 tests/regression_crypto_primitives.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dd2c3fb..685251b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - `jpar-list text` builtin: parse a JSON string and assert the top-level value is an array. Returns `R (L _) t`. `jpar-list! body` unwraps to `L _` directly, so `@x (jpar-list! body){...}` type-checks without a binding or type annotation. `jpar` is unchanged (returns `R _ t`); use `jpar-list` when the response is known to be a JSON array. - `get-to url timeout-ms > R t t` and `pst-to url body timeout-ms > R t t` builtins. HTTP GET and POST with an explicit per-request timeout in milliseconds. The timeout rounds up to the nearest whole second (minreq granularity). Returns `Err` when the deadline is exceeded, identical to any other connection failure from the caller's view. Both are tree-bridge eligible so VM and Cranelift JIT/AOT inherit them without new opcodes. Closes pending.md item #29. +- Crypto primitives bundle: `sha256 s > t`, `hmac-sha256 key body > t`, `base64-enc s > t`, `base64-dec s > R t t`, `base64url-enc s > t`, `base64url-dec s > R t t`, `hex-enc bytes:L n > t`, `hex-dec s > R (L n) t`, `ct-eq a b > b`. All are tree-bridge eligible (pure text/bytes ops, no FnRef args, no I/O); VM and Cranelift inherit via the existing bridge path with zero new opcodes. Dependencies: `sha2`, `hmac`, `base64`, `hex`, `subtle` crates. `sha256` and `hmac-sha256` return lowercase hex; `ct-eq` is constant-time, safe for comparing secrets (HMAC digests, tokens). `base64-dec`, `base64url-dec`, `hex-dec` return `R t/L n t` and can be auto-unwrapped with `!`. Validated by batch-1 personas needing webhook signature verification, JWT construction, and hex encode/decode workflows. Known vectors: SHA-256 empty/"abc", HMAC RFC 4231 test case 2, RFC 4648 base64 round-trips. - `rgxall-multi pats:L t s:t > L t` builtin. Apply multiple patterns to a single string and get one flat list of all hits in pattern order. Per-pattern semantics follow `rgxall1`: 0 capture groups returns whole matches; 1 capture group returns capture-1 strings; 2+ capture groups errors with a hint to use `rgxall`. Replaces the verbose `flat (map (p:t>L t;rgxall1 p line) pats)` workaround (~20 tokens per call site saved). Motivated by cron-explainer and historical-archeologist personas, which both needed multi-pattern scan on a single line. Tree-bridge eligible alongside `rgxall1`; no new opcodes. - `fmod a b` builtin: floor-mod, always non-negative when `b > 0`. Equivalent to Python `a % b` and JS `Math.floor((a % b + b) % b)`. Implemented across VM, JIT, and AOT. Eliminates the `(raw + 7) % 7` workaround that every TZ/weekday persona needed with signed `mod`. `mod` is unchanged (C-style signed remainder). - `dtparse-rel s now > R n t` builtin. Resolves a natural-language relative-date phrase to a Unix epoch anchored at `now`. Supported: `today`/`yesterday`/`tomorrow`, `N days/weeks/months ago`, `in N days/weeks/months` (singular + plural), `last/next/this ` (monday-sunday or mon-sun; `last`/`next` never return today), and ISO-8601 `YYYY-MM-DD` passthrough. Month arithmetic clamps to the last valid day (Jan 31 + 1 month = Feb 28/29). Tree-bridge eligible -- VM and Cranelift pick it up automatically. Eliminates ~40 LoC of date-arithmetic helpers per date persona (P1 #8 from the persona feedback log). diff --git a/SPEC.md b/SPEC.md index 0cd77164..e219c49b 100644 --- a/SPEC.md +++ b/SPEC.md @@ -593,6 +593,15 @@ Called like functions, compiled to dedicated opcodes. | `dtparse-rel s now` | parse relative-date phrase to epoch; `now` is the anchor epoch | `R n t` | | `dur-parse s` | parse human duration string ("3h 30m", "1 week 2 days", "1.5 hours", "90s") into seconds. Lenient: accepts abbreviations `s`/`m`/`h`/`d`/`w`, full names (singular + plural), decimal quantities, mixed sequences. Err if empty or no unit found | `R n t` | | `dur-fmt n` | format seconds as human-readable duration ("2h 42m", "1 day", "30s"). Drops zero parts; uses largest applicable units. Zero returns "0s". Negative values format with a leading "-" | `t` | +| `sha256 s` | SHA-256 hex digest (lowercase) of the UTF-8 bytes of `s` | `t` | +| `hmac-sha256 key body` | HMAC-SHA256 of `body` under `key`; returns lowercase hex. Use for webhook signature verification and API request signing | `t` | +| `base64-enc s` | standard base64 encode (RFC 4648 §4, with `=` padding) | `t` | +| `base64-dec s` | standard base64 decode; Err on invalid input (non-base64 chars) | `R t t` | +| `base64url-enc s` | base64url encode (RFC 4648 §5, no padding, URL-safe `-`/`_` alphabet). Use for JWT header/payload segments | `t` | +| `base64url-dec s` | base64url decode; Err on invalid input | `R t t` | +| `hex-enc bytes` | encode a list of integers 0-255 as lowercase hex string | `t` | +| `hex-dec s` | decode a hex string to a list of byte values (each 0-255). Err on odd-length or non-hex input | `R (L n) t` | +| `ct-eq a b` | constant-time text equality - returns `true` iff `a == b` without leaking timing info via short-circuit. Use when comparing secrets (HMAC digests, tokens) | `b` | | `rdjl path` | read JSONL file as `L (R _ t)`: one parse result per non-empty line | `L (R _ t)` | | `get-many urls` | concurrent HTTP GET fan-out (max 10 parallel), preserves order | `L (R t t)` | | `sleep ms` | pause current engine for `ms` milliseconds; returns nil | `_` | @@ -714,6 +723,44 @@ emits a single leading minus rather than signing each part. a fraction (`90.5 -> "1m 30.5s"`). Fractional minutes / hours / days / weeks are decomposed into smaller units before formatting. +### Crypto primitives + +`sha256`, `hmac-sha256`, `base64-enc`, `base64-dec`, `base64url-enc`, `base64url-dec`, `hex-enc`, `hex-dec`, `ct-eq` are tree-bridge eligible: they dispatch through the tree interpreter so VM and Cranelift share identical semantics. + +`sha256 s > t` — SHA-256 hex digest (lowercase) of the UTF-8 bytes of `s`. Input is always treated as text; if you need to hash raw bytes, encode them as a string via `hex-enc` first. + +`hmac-sha256 key body > t` — HMAC-SHA256 of `body` under `key`, lowercase hex. Both arguments are text. Typical use is webhook signature verification (`ct-eq (hmac-sha256 secret payload) sig`) and API request signing. + +`base64-enc s > t` / `base64-dec s > R t t` — standard base64 (RFC 4648 §4) with `=` padding. `base64-dec` returns `R t t`; the Ok branch holds the decoded text string (valid UTF-8), the Err branch holds the reason. Non-UTF-8 decoded bytes always Err. + +`base64url-enc s > t` / `base64url-dec s > R t t` — base64url (RFC 4648 §5): URL-safe `-`/`_` alphabet, no padding. Use for JWT header and payload segments. + +`hex-enc bytes:L n > t` — encode a list of integers 0-255 as lowercase hex. Each element must be a whole number in `[0, 255]`; ILO-R009 on out-of-range values. + +`hex-dec s > R (L n) t` — decode a hex string (upper- or lowercase) to a list of byte values (each 0-255). Err on odd-length input or non-hex characters. + +`ct-eq a:t b:t > b` — constant-time text equality. Returns `true` iff `a == b` without short-circuiting on the first differing byte, preventing timing attacks. Always use `ct-eq` rather than `==` when comparing secrets. + +``` +-- sha256 +sha256 "" -- e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +sha256 "abc" -- ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad +len (sha256 "x") -- 64 (always) + +-- hmac +hmac-sha256 "secret" "payload" -- lowercase hex + +-- base64 round-trip +base64-dec! (base64-enc "hello") -- "hello" +base64url-dec! (base64url-enc "hello") -- "hello" + +-- hex round-trip +hex-dec! (hex-enc [72, 101, 108, 108, 111]) -- [72, 101, 108, 108, 111] + +-- webhook verification pattern +verify sig:t body:t>b;expected=hmac-sha256 "secret" body;ct-eq expected sig +``` + ### Set operations `setunion`, `setinter`, `setdiff` operate on lists of `t`, `n`, or `b` (same constraint as `uniqby`). Output is deduped and sorted by a type-prefixed string key, so results are deterministic across runs and engines. Sort is lexicographic on the key, not numeric - re-sort with `srt` afterwards if you need numeric order. diff --git a/examples/crypto-primitives.ilo b/examples/crypto-primitives.ilo new file mode 100644 index 00000000..aa91ce55 --- /dev/null +++ b/examples/crypto-primitives.ilo @@ -0,0 +1,67 @@ +-- Crypto primitives: sha256, hmac-sha256, base64-enc/dec, base64url-enc/dec, +-- hex-enc/dec, ct-eq. All are tree-bridge eligible (pure text/bytes ops). + +-- sha256 s > t: SHA-256 hex digest (lowercase) of the UTF-8 bytes of s. +hash-empty>t;sha256 "" +hash-abc>t;sha256 "abc" +hash-len>n;len (sha256 "test") + +-- hmac-sha256 key body > t: HMAC-SHA256, returns lowercase hex. +-- Use for webhook signature verification and API request signing. +hmac-demo>t;hmac-sha256 "secret-key" "payload-data" +hmac-len>n;len (hmac-sha256 "k" "v") + +-- base64-enc s > t: standard base64 (RFC 4648, with padding). +-- base64-dec s > R t t: decode; Err on invalid input. +b64-enc>t;base64-enc "hello" +b64-dec>R t t;base64-dec "aGVsbG8=" +b64-roundtrip>R t t;base64-dec (base64-enc "hello, world!") + +-- base64url-enc s > t: base64url (no padding, URL-safe alphabet). +-- base64url-dec s > R t t: decode; Err on invalid input. +-- Use for JWT header and payload segments. +b64url-enc>t;base64url-enc "hello" +b64url-roundtrip>R t t;base64url-dec (base64url-enc "hello, world!") + +-- hex-enc bytes:L n > t: encode a list of integers 0-255 as lowercase hex. +-- hex-dec s > R (L n) t: decode hex string to list of byte values. +hex-enc-demo>t;hex-enc [0, 255, 16] +hex-dec-demo>R (L n) t;hex-dec "00ff10" +hex-roundtrip>R (L n) t;hex-dec (hex-enc [72, 101, 108, 108, 111]) + +-- ct-eq a:t b:t > b: constant-time text equality. No timing side-channels. +-- Use when comparing secrets such as HMAC digests or tokens. +ct-eq-true>b;ct-eq "abc" "abc" +ct-eq-false>b;ct-eq "abc" "xyz" +ct-eq-hmac>b;expected=hmac-sha256 "key" "payload";received=hmac-sha256 "key" "payload";ct-eq expected received + +-- run: hash-empty +-- out: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +-- run: hash-abc +-- out: ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad +-- run: hash-len +-- out: 64 +-- run: hmac-len +-- out: 64 +-- run: b64-enc +-- out: aGVsbG8= +-- run: b64-dec +-- out: hello +-- run: b64-roundtrip +-- out: hello, world! +-- run: b64url-enc +-- out: aGVsbG8 +-- run: b64url-roundtrip +-- out: hello, world! +-- run: hex-enc-demo +-- out: 00ff10 +-- run: hex-dec-demo +-- out: [0, 255, 16] +-- run: hex-roundtrip +-- out: [72, 101, 108, 108, 111] +-- run: ct-eq-true +-- out: true +-- run: ct-eq-false +-- out: false +-- run: ct-eq-hmac +-- out: true diff --git a/skills/ilo/ilo-builtins-text.md b/skills/ilo/ilo-builtins-text.md index e880dff6..7aec5f69 100644 --- a/skills/ilo/ilo-builtins-text.md +++ b/skills/ilo/ilo-builtins-text.md @@ -1,6 +1,6 @@ --- name: ilo-builtins-text -description: Use this when calling text builtins. Manipulation, regex, formatting (fmt, fmt2), CSV/TSV, and date parsing. +description: Use this when calling text builtins. Manipulation, regex, formatting (fmt, fmt2), CSV/TSV, date parsing, and crypto (sha256, hmac-sha256, base64, hex, ct-eq). --- # ilo builtins - text @@ -67,3 +67,31 @@ dur-fmt 90.5 -- "1m 30.5s" dur-fmt -90 -- "-1m 30s" dur-parse! "-1h 30m" -- -5400 (sticky sign) ``` + +## Crypto + +`sha256 s > t` — SHA-256 hex digest (lowercase, 64 chars) of UTF-8 bytes of `s`. + +`hmac-sha256 key body > t` — HMAC-SHA256 lowercase hex. Use for webhook sig verification and API signing. + +`base64-enc s > t` / `base64-dec s > R t t` — standard base64 (RFC 4648, with `=` padding). + +`base64url-enc s > t` / `base64url-dec s > R t t` — base64url (no padding, `-`/`_` alphabet). Use for JWT. + +`hex-enc bytes:L n > t` / `hex-dec s > R (L n) t` — hex encode/decode. Each byte must be 0-255. + +`ct-eq a:t b:t > b` — constant-time equality. **Always** use instead of `==` when comparing secrets. + +``` +sha256 "abc" -- ba7816bf... +hmac-sha256 "key" "payload" -- hex digest +base64-enc "hello" -- "aGVsbG8=" +base64-dec! "aGVsbG8=" -- "hello" +base64url-enc "hello" -- "aGVsbG8" (no padding) +hex-enc [255, 0, 16] -- "ff0010" +hex-dec! "ff0010" -- [255, 0, 16] +ct-eq sig expected -- bool, no timing leak + +-- webhook verification +verify sig:t body:t>b;expected=hmac-sha256 "secret" body;ct-eq expected sig +``` diff --git a/tests/regression_crypto_primitives.rs b/tests/regression_crypto_primitives.rs new file mode 100644 index 00000000..4a07dd37 --- /dev/null +++ b/tests/regression_crypto_primitives.rs @@ -0,0 +1,408 @@ +// Cross-engine regression tests for the crypto primitive builtins added in 0.12.1: +// sha256, hmac-sha256, base64-enc, base64-dec, base64url-enc, base64url-dec, +// hex-enc, hex-dec, ct-eq. +// +// All are tree-bridge eligible: VM and Cranelift dispatch through the tree +// interpreter so every engine shares the same semantics. Tests use known +// vectors (FIPS 180-4, RFC 4231, RFC 4648) to catch any future divergence. + +use std::process::Command; + +fn ilo() -> Command { + Command::new(env!("CARGO_BIN_EXE_ilo")) +} + +#[cfg(feature = "cranelift")] +const ENGINES: &[&str] = &["--vm", "--jit"]; +#[cfg(not(feature = "cranelift"))] +const ENGINES: &[&str] = &["--vm"]; + +fn run_ok(engine: &str, src: &str, entry: &str) -> String { + let out = ilo() + .args([src, engine, entry]) + .output() + .expect("failed to run ilo"); + assert!( + out.status.success(), + "ilo {engine} {src:?} {entry:?} failed: stderr={}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8_lossy(&out.stdout).trim().to_string() +} + +fn run_err(engine: &str, src: &str, entry: &str) -> String { + let out = ilo() + .args([src, engine, entry]) + .output() + .expect("failed to run ilo"); + assert!( + !out.status.success(), + "ilo {engine} {src:?} {entry:?} unexpectedly succeeded: stdout={}", + String::from_utf8_lossy(&out.stdout) + ); + String::from_utf8_lossy(&out.stderr).to_string() +} + +// ── sha256 ─────────────────────────────────────────────────────────────────── + +#[test] +fn sha256_empty_string() { + // FIPS 180-4 known vector: SHA-256("") = + // e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 + let src = r#"f>t;sha256 """#; + for e in ENGINES { + assert_eq!( + run_ok(e, src, "f"), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "engine={e}" + ); + } +} + +#[test] +fn sha256_abc() { + // SHA-256("abc") verified with coreutils sha256sum + let src = r#"f>t;sha256 "abc""#; + for e in ENGINES { + assert_eq!( + run_ok(e, src, "f"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "engine={e}" + ); + } +} + +#[test] +fn sha256_hello_world() { + // SHA-256("hello world") verified with coreutils sha256sum + let src = r#"f>t;sha256 "hello world""#; + for e in ENGINES { + assert_eq!( + run_ok(e, src, "f"), + "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", + "engine={e}" + ); + } +} + +#[test] +fn sha256_produces_64_char_hex() { + // SHA-256 output is always 32 bytes = 64 hex chars. + let src = r#"f>n;len (sha256 "test")"#; + for e in ENGINES { + assert_eq!(run_ok(e, src, "f"), "64", "engine={e}"); + } +} + +// ── hmac-sha256 ────────────────────────────────────────────────────────────── + +#[test] +fn hmac_sha256_rfc4231_ascii_key() { + // RFC 4231 Test Case 2: key = "Jefe", data = "what do ya want for nothing?" + // HMAC-SHA-256 = 5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843 + // Verified with: echo -n "what do ya want for nothing?" | openssl dgst -sha256 -hmac "Jefe" + let src = r#"f>t;hmac-sha256 "Jefe" "what do ya want for nothing?""#; + for e in ENGINES { + assert_eq!( + run_ok(e, src, "f"), + "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843", + "engine={e}" + ); + } +} + +#[test] +fn hmac_sha256_simple_key_and_data() { + // Well-known result for key="key", data="The quick brown fox jumps over the lazy dog" + // HMAC-SHA256 = f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8 + let src = r#"f>t;hmac-sha256 "key" "The quick brown fox jumps over the lazy dog""#; + for e in ENGINES { + assert_eq!( + run_ok(e, src, "f"), + "f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8", + "engine={e}" + ); + } +} + +#[test] +fn hmac_sha256_produces_64_char_hex() { + let src = r#"f>n;len (hmac-sha256 "k" "v")"#; + for e in ENGINES { + assert_eq!(run_ok(e, src, "f"), "64", "engine={e}"); + } +} + +// ── base64-enc / base64-dec ─────────────────────────────────────────────────── + +#[test] +fn base64_enc_known_vector() { + // RFC 4648 §10: base64("Man") = "TWFu" + let src = r#"f>t;base64-enc "Man""#; + for e in ENGINES { + assert_eq!(run_ok(e, src, "f"), "TWFu", "engine={e}"); + } +} + +#[test] +fn base64_enc_with_padding() { + // base64("Ma") = "TWE=" (one padding char) + let src = r#"f>t;base64-enc "Ma""#; + for e in ENGINES { + assert_eq!(run_ok(e, src, "f"), "TWE=", "engine={e}"); + } +} + +#[test] +fn base64_enc_hello() { + // base64("hello") = "aGVsbG8=" + let src = r#"f>t;base64-enc "hello""#; + for e in ENGINES { + assert_eq!(run_ok(e, src, "f"), "aGVsbG8=", "engine={e}"); + } +} + +#[test] +fn base64_dec_known_vector() { + // base64-dec("TWFu") = "Man" + let src = r#"f>R t t;base64-dec "TWFu""#; + for e in ENGINES { + assert_eq!(run_ok(e, src, "f"), "Man", "engine={e}"); + } +} + +#[test] +fn base64_dec_with_padding() { + let src = r#"f>R t t;base64-dec "TWE=""#; + for e in ENGINES { + assert_eq!(run_ok(e, src, "f"), "Ma", "engine={e}"); + } +} + +#[test] +fn base64_round_trip() { + let src = r#"f>R t t;base64-dec (base64-enc "hello, world!")"#; + for e in ENGINES { + assert_eq!(run_ok(e, src, "f"), "hello, world!", "engine={e}"); + } +} + +#[test] +fn base64_dec_invalid_errors() { + // "!!!" is not valid base64 — should return Err not crash. + let src = r#"f>t;r=base64-dec "!!!";?r{~_:"ok";^_:"err"}"#; + for e in ENGINES { + assert_eq!(run_ok(e, src, "f"), "err", "engine={e}"); + } +} + +// ── base64url-enc / base64url-dec ──────────────────────────────────────────── + +#[test] +fn base64url_enc_no_padding() { + // base64url("hello") = "aGVsbG8" (no padding, URL-safe alphabet) + let src = r#"f>t;base64url-enc "hello""#; + for e in ENGINES { + assert_eq!(run_ok(e, src, "f"), "aGVsbG8", "engine={e}"); + } +} + +#[test] +fn base64url_enc_no_plus_or_slash() { + // base64url uses - and _ instead of + and /. Verify the output never contains + // standard base64 chars that would be URL-unsafe. + let src = r#"f>b;s=base64url-enc "hello world this is a test string for url safety";has s "+""#; + for e in ENGINES { + assert_eq!(run_ok(e, src, "f"), "false", "engine={e}"); + } + let src2 = r#"f>b;s=base64url-enc "hello world this is a test string for url safety";has s "/""#; + for e in ENGINES { + assert_eq!(run_ok(e, src2, "f"), "false", "engine={e}"); + } +} + +#[test] +fn base64url_round_trip() { + let src = r#"f>R t t;base64url-dec (base64url-enc "hello, world!")"#; + for e in ENGINES { + assert_eq!(run_ok(e, src, "f"), "hello, world!", "engine={e}"); + } +} + +#[test] +fn base64url_dec_invalid_errors() { + let src = r#"f>t;r=base64url-dec "!!!";?r{~_:"ok";^_:"err"}"#; + for e in ENGINES { + assert_eq!(run_ok(e, src, "f"), "err", "engine={e}"); + } +} + +// ── hex-enc / hex-dec ───────────────────────────────────────────────────────── + +#[test] +fn hex_enc_simple() { + // hex-enc [0, 255, 16] -> "00ff10" + let src = "f>t;hex-enc [0, 255, 16]"; + for e in ENGINES { + assert_eq!(run_ok(e, src, "f"), "00ff10", "engine={e}"); + } +} + +#[test] +fn hex_enc_empty_list() { + let src = "f>t;hex-enc []"; + for e in ENGINES { + assert_eq!(run_ok(e, src, "f"), "", "engine={e}"); + } +} + +#[test] +fn hex_enc_all_zeros() { + let src = "f>t;hex-enc [0, 0, 0]"; + for e in ENGINES { + assert_eq!(run_ok(e, src, "f"), "000000", "engine={e}"); + } +} + +#[test] +fn hex_dec_simple() { + // hex-dec "00ff10" -> [0, 255, 16] + let src = r#"f>R (L n) t;hex-dec "00ff10""#; + for e in ENGINES { + assert_eq!(run_ok(e, src, "f"), "[0, 255, 16]", "engine={e}"); + } +} + +#[test] +fn hex_dec_empty_string() { + let src = r#"f>R (L n) t;hex-dec """#; + for e in ENGINES { + assert_eq!(run_ok(e, src, "f"), "[]", "engine={e}"); + } +} + +#[test] +fn hex_round_trip() { + // hex-dec(hex-enc(bytes)) == bytes + let src = "f>R (L n) t;bytes=[72, 101, 108, 108, 111];hex-dec (hex-enc bytes)"; + for e in ENGINES { + assert_eq!(run_ok(e, src, "f"), "[72, 101, 108, 108, 111]", "engine={e}"); + } +} + +#[test] +fn hex_dec_uppercase_input() { + // hex-dec should accept uppercase hex too + let src = r#"f>R (L n) t;hex-dec "DEADBEEF""#; + for e in ENGINES { + assert_eq!(run_ok(e, src, "f"), "[222, 173, 190, 239]", "engine={e}"); + } +} + +#[test] +fn hex_dec_odd_length_errors() { + let src = r#"f>t;r=hex-dec "abc";?r{~_:"ok";^_:"err"}"#; + for e in ENGINES { + assert_eq!(run_ok(e, src, "f"), "err", "engine={e}"); + } +} + +#[test] +fn hex_dec_invalid_chars_errors() { + let src = r#"f>t;r=hex-dec "zz";?r{~_:"ok";^_:"err"}"#; + for e in ENGINES { + assert_eq!(run_ok(e, src, "f"), "err", "engine={e}"); + } +} + +#[test] +fn hex_enc_out_of_range_errors() { + // 256 is not a valid byte value — should error on VM. + let src = "f>t;hex-enc [256]"; + let stderr = run_err("--vm", src, "f"); + assert!( + stderr.contains("hex-enc"), + "VM: expected hex-enc error, got: {stderr}" + ); +} + +// ── ct-eq ──────────────────────────────────────────────────────────────────── + +#[test] +fn ct_eq_equal_strings() { + let src = r#"f>b;ct-eq "hello" "hello""#; + for e in ENGINES { + assert_eq!(run_ok(e, src, "f"), "true", "engine={e}"); + } +} + +#[test] +fn ct_eq_different_strings() { + let src = r#"f>b;ct-eq "hello" "world""#; + for e in ENGINES { + assert_eq!(run_ok(e, src, "f"), "false", "engine={e}"); + } +} + +#[test] +fn ct_eq_different_lengths() { + let src = r#"f>b;ct-eq "hi" "hello""#; + for e in ENGINES { + assert_eq!(run_ok(e, src, "f"), "false", "engine={e}"); + } +} + +#[test] +fn ct_eq_empty_strings() { + let src = r#"f>b;ct-eq "" """#; + for e in ENGINES { + assert_eq!(run_ok(e, src, "f"), "true", "engine={e}"); + } +} + +#[test] +fn ct_eq_empty_vs_nonempty() { + let src = r#"f>b;ct-eq "" "x""#; + for e in ENGINES { + assert_eq!(run_ok(e, src, "f"), "false", "engine={e}"); + } +} + +#[test] +fn ct_eq_hmac_comparison_pattern() { + // The primary use-case: compare HMAC digests without leaking timing. + let src = concat!( + r#"f>b;"#, + r#"expected=hmac-sha256 "secret" "payload";"#, + r#"received=hmac-sha256 "secret" "payload";"#, + r#"ct-eq expected received"# + ); + for e in ENGINES { + assert_eq!(run_ok(e, src, "f"), "true", "engine={e}"); + } +} + +#[test] +fn ct_eq_wrong_type_errors_on_vm() { + let src = "f>b;ct-eq 42 42"; + let stderr = run_err("--vm", src, "f"); + assert!( + stderr.contains("ct-eq"), + "VM: expected ct-eq error, got: {stderr}" + ); +} + +// ── integration: sha256 -> hex-enc pipeline ────────────────────────────────── + +#[test] +fn sha256_output_is_valid_hex_decodable() { + // sha256 returns hex — hex-dec should decode it to 32 bytes. + let src = r#"f>R (L n) t;hex-dec (sha256 "test")"#; + for e in ENGINES { + let out = run_ok(e, src, "f"); + // Result is a list of 32 numbers + assert!(out.starts_with('['), "engine={e}: expected list, got: {out}"); + let count = out.split(',').count(); + assert_eq!(count, 32, "engine={e}: expected 32 bytes, got {count}: {out}"); + } +} From 4f98c0da912811f6201e4577e98b57cb1d5e21b7 Mon Sep 17 00:00:00 2001 From: Daniel Morris Date: Wed, 20 May 2026 19:31:29 +0100 Subject: [PATCH 05/10] chore: apply cargo fmt to interpreter and tests --- src/interpreter/mod.rs | 15 ++++++--------- tests/regression_crypto_primitives.rs | 19 +++++++++++++++---- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/src/interpreter/mod.rs b/src/interpreter/mod.rs index 81cf02e0..89b18e73 100644 --- a/src/interpreter/mod.rs +++ b/src/interpreter/mod.rs @@ -3930,7 +3930,10 @@ fn call_function(env: &mut Env, name: &str, args: Vec) -> Result { other => { return Err(RuntimeError::new( "ILO-R009", - format!("hex-enc requires a list of numbers (0-255), got {:?}", other), + format!( + "hex-enc requires a list of numbers (0-255), got {:?}", + other + ), )); } }; @@ -3942,10 +3945,7 @@ fn call_function(env: &mut Env, name: &str, args: Vec) -> Result { if !(0..=255).contains(&b) || n.fract() != 0.0 { return Err(RuntimeError::new( "ILO-R009", - format!( - "hex-enc: element {} ({}) is not an integer in 0-255", - i, n - ), + format!("hex-enc: element {} ({}) is not an integer in 0-255", i, n), )); } byte_buf.push(b as u8); @@ -3953,10 +3953,7 @@ fn call_function(env: &mut Env, name: &str, args: Vec) -> Result { other => { return Err(RuntimeError::new( "ILO-R009", - format!( - "hex-enc: element {} must be a number, got {:?}", - i, other - ), + format!("hex-enc: element {} must be a number, got {:?}", i, other), )); } } diff --git a/tests/regression_crypto_primitives.rs b/tests/regression_crypto_primitives.rs index 4a07dd37..e68e441e 100644 --- a/tests/regression_crypto_primitives.rs +++ b/tests/regression_crypto_primitives.rs @@ -215,7 +215,8 @@ fn base64url_enc_no_plus_or_slash() { for e in ENGINES { assert_eq!(run_ok(e, src, "f"), "false", "engine={e}"); } - let src2 = r#"f>b;s=base64url-enc "hello world this is a test string for url safety";has s "/""#; + let src2 = + r#"f>b;s=base64url-enc "hello world this is a test string for url safety";has s "/""#; for e in ENGINES { assert_eq!(run_ok(e, src2, "f"), "false", "engine={e}"); } @@ -286,7 +287,11 @@ fn hex_round_trip() { // hex-dec(hex-enc(bytes)) == bytes let src = "f>R (L n) t;bytes=[72, 101, 108, 108, 111];hex-dec (hex-enc bytes)"; for e in ENGINES { - assert_eq!(run_ok(e, src, "f"), "[72, 101, 108, 108, 111]", "engine={e}"); + assert_eq!( + run_ok(e, src, "f"), + "[72, 101, 108, 108, 111]", + "engine={e}" + ); } } @@ -401,8 +406,14 @@ fn sha256_output_is_valid_hex_decodable() { for e in ENGINES { let out = run_ok(e, src, "f"); // Result is a list of 32 numbers - assert!(out.starts_with('['), "engine={e}: expected list, got: {out}"); + assert!( + out.starts_with('['), + "engine={e}: expected list, got: {out}" + ); let count = out.split(',').count(); - assert_eq!(count, 32, "engine={e}: expected 32 bytes, got {count}: {out}"); + assert_eq!( + count, 32, + "engine={e}: expected 32 bytes, got {count}: {out}" + ); } } From 7c7369516d3dc340538a1d76b4a6a9b4a9105fdf Mon Sep 17 00:00:00 2001 From: Daniel Morris Date: Wed, 20 May 2026 19:42:55 +0100 Subject: [PATCH 06/10] fix: trim ilo-builtins-text.md under 1000-token CI budget Compact the Duration and Crypto sections without losing any information agents actually use. Duration's full-sentence prose trimmed to one-line descriptions; Crypto example block trimmed to 6 lines. Total 972 tokens (was 1275), budget passes. --- skills/ilo/ilo-builtins-text.md | 40 +++++++++------------------------ 1 file changed, 11 insertions(+), 29 deletions(-) diff --git a/skills/ilo/ilo-builtins-text.md b/skills/ilo/ilo-builtins-text.md index 7aec5f69..752f31d5 100644 --- a/skills/ilo/ilo-builtins-text.md +++ b/skills/ilo/ilo-builtins-text.md @@ -54,44 +54,26 @@ last-week-start nw:n>n;dtparse-rel!! "last monday" nw ## Duration -`dur-parse s > R n t` — parse human duration string into seconds. Accepts `s/m/h/d/w` abbreviations, full names (week/day/hour/minute/second, singular + plural), decimal quantities, mixed sequences ("3h 30m", "1.5 hours", "1 week 2 days", "90s"). Months are **not** supported ("3mo", "3 months" both error — a month is not a fixed number of seconds; use day counts instead). A leading `-` is sticky: it applies to every following token until an explicit `+` resets it, so `"-1m 30s"` = `-90`. Err if empty or no unit found. +`dur-parse s > R n t` — parse human duration into seconds. Accepts `s/m/h/d/w`, full names (singular + plural), decimals, mixed ("3h 30m", "1.5 hours", "1 week 2 days"). Months unsupported (not fixed length). Leading `-` is sticky: `"-1m 30s"` = -90. Err if empty or no unit found. -`dur-fmt n > t` — format seconds as human-readable duration. Drops zero parts; uses largest units ("2h 42m", "1 day", "30s"). Zero returns "0s". Negative values emit a single leading minus (`-90` → `"-1m 30s"`) which round-trips back through `dur-parse`. Fractional seconds are preserved with up to 3 decimal places, trailing zeros stripped (`90.5` → `"1m 30.5s"`, `0.5` → `"0.5s"`). +`dur-fmt n > t` — seconds to human-readable. Drops zero parts; largest units. Zero = "0s". Negative emits leading minus. Fractional seconds preserved up to 3dp. ``` -secs = dur-parse! "3h 30m" -- 12600 -dur-fmt secs -- "3h 30m" -dur-fmt 86400 -- "1 day" -dur-fmt 90 -- "1m 30s" -dur-fmt 90.5 -- "1m 30.5s" -dur-fmt -90 -- "-1m 30s" -dur-parse! "-1h 30m" -- -5400 (sticky sign) +dur-parse! "3h 30m" -- 12600 +dur-fmt 9720 -- "2h 42m" +dur-fmt -90 -- "-1m 30s" +dur-parse! "-1h 30m" -- -5400 (sticky sign) ``` ## Crypto -`sha256 s > t` — SHA-256 hex digest (lowercase, 64 chars) of UTF-8 bytes of `s`. - -`hmac-sha256 key body > t` — HMAC-SHA256 lowercase hex. Use for webhook sig verification and API signing. - -`base64-enc s > t` / `base64-dec s > R t t` — standard base64 (RFC 4648, with `=` padding). - -`base64url-enc s > t` / `base64url-dec s > R t t` — base64url (no padding, `-`/`_` alphabet). Use for JWT. - -`hex-enc bytes:L n > t` / `hex-dec s > R (L n) t` — hex encode/decode. Each byte must be 0-255. - -`ct-eq a:t b:t > b` — constant-time equality. **Always** use instead of `==` when comparing secrets. +`sha256 s > t` SHA-256 lowercase hex. `hmac-sha256 key body > t` HMAC-SHA256 hex (webhook signing, API auth). `base64-enc/dec s > t/R t t` standard base64 with `=` padding. `base64url-enc/dec s > t/R t t` url-safe no-pad (JWT). `hex-enc bytes:L n > t` 0-255 list to hex. `hex-dec s > R (L n) t` hex to byte list. `ct-eq a b > b` constant-time equality — use instead of `==` for secrets. ``` sha256 "abc" -- ba7816bf... -hmac-sha256 "key" "payload" -- hex digest -base64-enc "hello" -- "aGVsbG8=" -base64-dec! "aGVsbG8=" -- "hello" -base64url-enc "hello" -- "aGVsbG8" (no padding) -hex-enc [255, 0, 16] -- "ff0010" -hex-dec! "ff0010" -- [255, 0, 16] +hmac-sha256 "key" "payload" -- 64-char hex +base64-dec! (base64-enc "hi") -- "hi" +hex-dec! (hex-enc [255,0,16]) -- [255,0,16] ct-eq sig expected -- bool, no timing leak - --- webhook verification -verify sig:t body:t>b;expected=hmac-sha256 "secret" body;ct-eq expected sig +-- webhook: verify sig:t body:t>b;ct-eq (hmac-sha256 "secret" body) sig ``` From 59421cbdd1446061af37dffd45a852c037f289d0 Mon Sep 17 00:00:00 2001 From: Daniel Morris Date: Wed, 20 May 2026 19:48:22 +0100 Subject: [PATCH 07/10] fix: regenerate ai.txt from SPEC.md via build.rs My manual edit to ai.txt placed crypto entries in the wrong position. build.rs generates ai.txt from the SPEC.md table in document order; the crypto rows appear after rdjl/get-many, not after default-on-err. Committing the build.rs output so CI passes. --- ai.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ai.txt b/ai.txt index a3cdf007..d4c0c7b8 100644 --- a/ai.txt +++ b/ai.txt @@ -6,7 +6,7 @@ NAMING: Short names everywhere. 1–3 chars. `order`=`ord`=truncate `customers`= COMMENTS: -- full line comment +a b -- end of line comment -- no multi-line comments; use consecutive -- lines -- like this Single-line only. `--` to end of line. No multi-line comment syntax - newlines are a human display concern, not a language concern. An entire ilo program can be one line. Use consecutive `--` lines when humans need multi-line comments. Stripped at the lexer level before parsing - comments produce no AST nodes and cost zero runtime tokens. Generating `--` costs 1 LLM token, so comments are essentially free. **Gotcha:** `--x 1` is a comment, not "negate (x minus 1)". The lexer matches `--` greedily as a comment and eats the rest of the line. To negate a subtraction, use a space or bind first: -- DON'T: --x 1 (comment, not negate-subtract) -- DO: - -x 1 (space separates the two minus operators) -- DO: r=-x 1;-r (bind first) OPERATORS: Both prefix and infix notation are supported. **Prefix is preferred** - it is the token-optimal form that eliminates parentheses and produces denser code. Infix is available for readability when needed. [Binary] `+a b`=`a + b`=add / concat / list concat=`n`, `t`, `L` `+=a v`=append to list (returns new list, see [Append semantics](#append-semantics-+=))=`L` `-a b`=`a - b`=subtract=`n` `*a b`=`a * b`=multiply=`n` `/a b`=`a / b`=divide=`n` `=a b`=`a == b`=equal (prefix `=` is preferred; `==a b` also accepted)=any `!=a b`=`a != b`=not equal=any `>a b`=`a > b`=greater than=`n`, `t` `=a b`=`a >= b`=greater or equal=`n`, `t` `<=a b`=`a <= b`=less or equal=`n`, `t` `&a b`=`a & b`=logical AND (short-circuit)=any (truthy) `|a b`=`a | b`=logical OR (short-circuit)=any (truthy) [Append semantics (`+=`)] `+=xs v` is **pure-shaped**, despite the imperative-looking syntax. It returns a new list with `v` appended and does **not** mutate `xs` in the caller's scope. It works in every position a value-producing expression works: -- 1. Rebind (canonical accumulator pattern) xs=[];@i 0..3{xs=+=xs i};xs -- [0, 1, 2] -- 2. Non-rebind assignment (xs preserved) xs=[1, 2, 3];ys=+=xs 99 -- xs is still [1, 2, 3]; ys is [1, 2, 3, 99] -- 3. Pipeline / argument position len +=xs 99 -- length of [xs..., 99] sum +=xs 99 -- sum of [xs..., 99] The rebind shape `xs = +=xs v` is the standard foreach-build accumulator. When the binding is RC=1 the engines mutate the underlying buffer in place (amortised O(1) per push) - but this is a behind-the-scenes optimisation. To any observer the operation is still functional: nothing outside the rebind sees the old `xs`. The non-rebind shape `ys = +=xs v` always allocates a fresh list and leaves `xs` untouched, so source aliases are safe. There is no separate `push` builtin. `+=` covers every use case and is shorter; adding an alias would mean two ways to spell the same operation, costing reasoning tokens and surface area. [Unary] `-x`=negate=`n` `!x`=logical NOT=any (truthy) [Special infix] `a??b`=nil-coalesce (if a is nil, return b)=any `a>>f`=pipe (desugar to `f(a)`)=any [Prefix nesting (no parens needed)] +*a b c -- (a * b) + c *a +b c -- a * (b + c) >=+x y 100 -- (x + y) >= 100 -*a b *c d -- (a * b) - (c * d) The outer prefix op binds the inner prefix subexpression as its **left** operand, regardless of operator precedence. With two same-precedence ops side by side this is easy to misread: */a b c -- (a/b) * c ← NOT (a*b)/c /*a b c -- (a*b) / c ← NOT (a/b)*c +-a b c -- (a-b) + c ← NOT (a+b)-c -+a b c -- (a+b) - c ← NOT (a-b)+c The runtime emits a `hint:` diagnostic when one of these four pairs appears at a prefix position, since the parse order disagrees with the natural left-to-right reading. To force the other grouping, swap the ops or bind the inner result first: -- Want (a*b)/c with a=6, b=2, c=3: r=*a b;/r c -- bind, then divide → 4 /*a b c -- equivalent, swapping the prefix-pair order [Infix precedence] Standard mathematical precedence (higher binds tighter): 6=`*` `/` 5=`+` `-` `+=` 4=`>` `<` `>=` `<=` 3=`=` `!=` 2=`&` 1=`|` Function application binds tighter than all infix operators: f a + b -- (f a) + b, NOT f(a + b) x * y + 1 -- (x * y) + 1 (x + y) * 2 -- parens override precedence Each nested prefix operator saves 2 tokens (no `(` `)` needed). Flat prefix like `+a b` saves 1 char vs `a + b`. Across 25 expression patterns, prefix notation saves **22% tokens** and **42% characters** vs infix. See [research/explorations/prefix-vs-infix/](research/explorations/prefix-vs-infix/) for the full benchmark. Disambiguation: `-` followed by one atom is unary negate, followed by two atoms is binary subtract. [Operands] Operator operands are **atoms** (literals, refs, field access), **nested prefix operators**, or **known-arity function calls**. The prefix-binop operand parser dispatches to call parsing when the ident at the cursor is a known-arity user fn or builtin AND the next token can start another operand: wh >len q 0{body} -- parses as wh > (len q) 0 { body } +f g h -- if f is 1-arity: BinOp(+, Call(f, [g]), h) -lnx 5 lnx 3 -- BinOp(-, Call(lnx, [5]), Call(lnx, [3])) dbl 5 -- Negate(Call(dbl, [5])) - unary on a call This parallels the `??` precedent: `??x default` accepts a call expression on the value side. Applies to every prefix-binop family member - `+`, `-`, `*`, `/`, comparisons, `&`, `|`, `+=` - and to unary negate when the call consumes the only operand. The same expansion also applies to the then/else slots of the prefix-ternary family (`?=cond a b`, `?>cond a b`, …) and the `?h cond a b` keyword form, so `?h =a b sev sc "NONE"` parses `sev sc` as a nested call without parens or a bind-first. Bare locals that shadow a user fn name still resolve via `Ref` rather than expanding into a zero-arg call, so `&e f{...}` where `f` is a local still parses as the bool operator with two refs. When the call expansion isn't available (the ident is a local that shadows a fn name, or the call's arity doesn't fit the remaining tokens), bind the call result first: r=fac p;*n r -- bind, then operate - always unambiguous **Negative literals vs binary minus**: the lexer greedily includes a leading `-` into number tokens. `-1`, `-7`, `-0` are all number literals at fresh-expression positions. To subtract from zero at the start of a statement, use a space: `- 0 v` (Minus token, then `0`, then `v`). f v:n>n;-0 v -- WRONG: -0 is Number(-0.0); v is a stray token f v:n>n;- 0 v -- OK: binary subtract: 0 - v = -v The lexer splits a glued negative literal back into `Minus + Number` when the previous token is one of `;`, `\n`, `=`, `{`, `(`, or `-`. The `-` context covers the operand slot of an outer prefix-minus, so `- -0 a b` lexes as `-, -, 0, a, b` and parses as `Subtract(Subtract(0, a), b)` = `-a - b` rather than tripping `ILO-P020`. Negative literals after an Ident, `[`, or another prefix binop (`+`, `*`, `/`) stay glued so call args (`at xs -1`), list literals (`[-2 1 3]`), and binary operands (`+a -3`) read naturally. STRING LITERALS: Text values are written in double quotes. Escape sequences: `\n`=newline (0x0A) `\t`=tab (0x09) `\r`=carriage return (0x0D) `\f`=form feed (0x0C, PDF page separator) `\b`=backspace (0x08) `\v`=vertical tab (0x0B) `\a`=bell (0x07) `\0`=null (0x00) `\"`=literal double quote `\\`=literal backslash `\/`=literal forward slash (JSON passthrough) Unknown escapes (e.g. `\z`) preserve the backslash + char verbatim. "hello\nworld" -- two-line string "col1\tcol2" -- tab-separated spl text "\n" -- split file content into lines spl pdf "\f" -- split pdftotext output into pages -BUILTINS: Called like functions, compiled to dedicated opcodes. `len x`=length of string (bytes) or list (elements)=`n` `str n`=number to text (integers format without `.0`)=`t` `num t`=text to number; trims leading/trailing ASCII whitespace before parsing (Err if unparseable)=`R n t` `abs n`=absolute value=`n` `min a b`=minimum of two numbers=`n` `min xs`=minimum element of a numeric list (error if empty)=`n` `max a b`=maximum of two numbers=`n` `max xs`=maximum element of a numeric list (error if empty)=`n` `mod a b`=C-style signed remainder; result sign matches dividend. Errors on zero divisor. For negative inputs use `fmod`.=`n` `fmod a b`=Floor-mod: always non-negative when `b > 0`. Equivalent to Python `a % b`. Errors on zero divisor. NaN/Inf inputs propagate via IEEE 754 (same policy as every other math builtin). Use instead of `(a % b + b) % b` workarounds for weekday/timezone arithmetic.=`n` `flr n`=floor (round toward negative infinity)=`n` `cel n`=ceiling (round toward positive infinity)=`n` `rnd`=random float in [0, 1). NOT round - for round use `rou` (alias: `round`). Aliases: `rand`, `random`.=`n` `rnd a b`=random integer in [a, b] (inclusive)=`n` `now`=current Unix timestamp (seconds)=`n` `now-ms`=current Unix timestamp (milliseconds)=`n` `get url`=HTTP GET=`R t t` `get url headers`=HTTP GET with custom headers (`M t t` map)=`R t t` `get-to url timeout-ms`=HTTP GET with explicit timeout (milliseconds); Err if deadline exceeded=`R t t` `pst url body`=HTTP POST with text body (renamed from `post` in 0.12.0)=`R t t` `pst url body headers`=HTTP POST with body and custom headers (`M t t` map)=`R t t` `pst-to url body timeout-ms`=HTTP POST with explicit timeout (milliseconds); Err if deadline exceeded=`R t t` `run cmd argv`=spawn `cmd` with argv list — see [Process spawn](#process-spawn) for the no-shell-no-glob security model=`R (M t t) t` `env key`=read environment variable=`R t t` `env-all`=snapshot the full process environment as `M t t`=`R (M t t) t` `rd path`=read file; format auto-detected from extension (`.csv`/`.tsv`→grid, `.json`→graph, else text)=`R _ t` `rd path fmt`=read file with explicit format override (`"csv"`, `"tsv"`, `"json"`, `"raw"`)=`R _ t` `rdl path`=read file as list of lines=`R (L t) t` `rdin`=read all of stdin as text; Err on I/O failure or WASM=`R t t` `rdinl`=read stdin as list of lines (newlines stripped); Err on I/O failure or WASM=`R (L t) t` `lsd dir`=list directory entries (filenames only, not full paths; sorted lexicographically; includes both files and subdirs; empty dirs return `[]`, not Err). Renamed from `ls` in 0.12.1 so the natural `ls=rdl! p` binding for "lines" stays free.=`R (L t) t` `walk dir`=recursive depth-first traversal; paths returned relative to `dir`, sorted; includes both file and directory entries; symlinks not followed. Unreadable subdirectories (e.g. permission denied) are silently skipped so one locked sibling does not poison the whole walk; an unreadable root still returns `Err`=`R (L t) t` `glob dir pat`=shell-style filter under `dir`: `*`/`?`/`[abc]` within a path segment, `**` across segments; relative-path output, sorted; no matches returns `[]` (not Err). Shares `walk`'s traversal so unreadable subdirectories are skipped silently=`R (L t) t` `dirname path`=POSIX-style parent directory. `dirname "/a/b/c.txt"` → `"/a/b"`, `dirname "/"` → `"/"`, `dirname "foo.txt"` → `""` (POSIX returns `"."` here; ilo returns `""` so `pathjoin [dirname p basename p]` round-trips a plain filename without a phantom `./` prefix), `dirname "foo/"` → `""` (trailing slash stripped, then no directory component remains), `dirname "/a"` → `"/"`. Pure text op, no I/O, no Result. Unix forward-slash semantics; Windows separator handling is a 0.13.0 concern=`t` `basename path`=POSIX-style final path segment. `basename "/a/b/c.txt"` → `"c.txt"`, `basename "/"` → `"/"`, `basename "foo/"` → `"foo"` (trailing slash stripped), `basename ""` → `""`. Pure text op, total=`t` `pathjoin parts`=join a list of path segments with `/`, collapsing duplicate separators at joints and dropping empty segments. `pathjoin ["a" "b" "c.txt"]` → `"a/b/c.txt"`, `pathjoin ["a/" "/b/" "c.txt"]` → `"a/b/c.txt"`, `pathjoin []` → `""`, `pathjoin ["/" "a"]` → `"/a"` (leading absolute root preserved). List form (not variadic) so arity inference stays predictable; matches `cat xs sep`'s shape=`t` `fsize path`=file size in bytes (follows symlinks); `Err` on missing, permission-denied, or path-is-directory. Paired predicate `isfile` collapses the error tier into `false` for one-token branches=`R n t` `mtime path`=last modification time as Unix epoch seconds (`f64`, fractional preserved; follows symlinks); `Err` on missing or permission-denied. Pairs with `now` for "is this file older than N seconds" checks=`R n t` `isfile path`=`true` iff `path` resolves to a regular file (follows symlinks). Missing, permission-denied, or non-file all collapse to `false` — natural shape for `?isfile p{…}`. Asymmetric vs `fsize`/`mtime` (which return `R n t`) by design: predicates want a one-token branch, size/mtime callers want to distinguish missing from perm-denied=`b` `isdir path`=`true` iff `path` resolves to a directory (follows symlinks). Same `false`-on-failure collapse as `isfile`=`b` `rdb s fmt`=parse string/buffer in given format - for data from HTTP, env vars, etc.=`R _ t` `wr path s`=write text to file (overwrite)=`R t t` `wr path data "csv"`=write list-of-lists as CSV (with proper quoting)=`R t t` `wr path data "tsv"`=write list-of-lists as TSV=`R t t` `wr path data "json"`=write any value as pretty JSON=`R t t` `wra path s`=append text to file (create if missing)=`R t t` `wrl path xs`=write list of lines to file (joins with `\n`)=`R t t` `trm s`=trim leading and trailing whitespace=`t` `spl t sep`=split text by separator=`L t` `fmt tmpl args…`=format string - bare `{}` placeholders only, filled left-to-right. Printf-style specs (`{:06d}`, `{:.3f}`) are rejected; compose `fmt2` for decimal precision and `padl` for width/padding. Literal templates require `{}`-count == arg-count (verifier rejects mismatches with `ILO-T013`). Lists are formatted as a single value, not splatted: `fmt "{} {}" [a, b]` is an error - use `fmt "{} {}" a b` instead=`t` `cat xs sep`=join list of text with separator=`t` `has xs v`=membership test (list: element, text: substring)=`b` `hd xs`=head (first element/char) of list or text=element / `t` `tl xs`=tail (all but first) of list or text=`L` / `t` `rev xs`=reverse list or text=same type `srt xs`=sort list (all-number or all-text) or text chars=same type `srt fn xs`=sort list by key function (returns number or text key)=`L` `unq xs`=remove duplicates, preserve order (list or text chars)=same type `slc xs a b`=slice list or text from index a to b (a, b accept negative indices counting from end; bounds clamp)=same type `jpth json path`=JSON dot-path lookup, dot-separated keys + numeric array indices (e.g. `"a.b.0.c"`), not JSONPath - leading `$`, `*`, or `[...]` rejected with a diagnostic. Result is typed: arrays → list, objects → record, scalars → matching primitive.=`R _ t` `jkeys json path`=sorted top-level keys of the JSON object at `path` (empty path = root). Err if the value at the path is not an object.=`R (L t) t` `jdmp value`=serialise ilo value to JSON text=`t` `prnt value`=print value to stdout, return it unchanged (passthrough)=same type `jpar text`=parse JSON text into ilo values=`R _ t` `jpar-list text`=parse JSON text, assert top-level is array, return typed list=`R (L _) t` `grp fn xs`=group list by key function=`M t (L a)` `flat xs`=flatten one level of nesting=`L a` `sum xs`=sum of numeric list (0 for empty)=`n` `prod xs`=product of numeric list (1 for empty)=`n` `avg xs`=mean of numeric list (error if empty)=`n` `rgx pat s`=regex: no groups→all matches; groups→first match captures=`L t` `mmap`=create empty map=`M t _` `mget m k`=value at key k (nil if missing)=element or nil `mset m k v`=new map with key k set to v=`M k v` `mhas m k`=true if key exists=`b` `mkeys m`=sorted list of keys=`L t` `mvals m`=values sorted by key=`L v` `mpairs m`=sorted [k, v] pairs; `mpairs m == zip (mkeys m) (mvals m)`=`L (L _)` `mdel m k`=new map with key k removed=`M k v` `mget-or m k default`=value at key k, or `default` if missing (never nil; default type must match value type)=`v` `at xs i`=i-th element of list or text (0-indexed; negative counts from end; float `i` auto-floors)=element `lget-or xs i default`=element at index `i`, or `default` if OOB (negative indices like `at`; never errors on OOB)=`a` `lst xs i v`=new list with index `i` set to `v` (list update; alias: `lset`)=`L a` `take n xs`=first `n` elements/chars of list or text (n>=0 truncates if n>len; n<0 keeps all but the last `abs n`, Python `xs[:n]`)=same type `drop n xs`=skip first `n` elements/chars (n>=0 returns the rest; n<0 keeps only the last `abs n`, Python `xs[n:]`)=same type `rsrt xs`=sort descending (list or text chars)=same type `rsrt fn xs`=sort descending by key function (returns number or text key)=`L` `rsrt fn ctx xs`=sort descending by key function with explicit ctx arg (closure-bind alternative; `fn` takes `(elem, ctx)`)=`L` `uniqby fn xs`=dedupe by key function (first occurrence wins)=`L a` `zip xs ys`=pairwise pairs of two lists; truncates to shorter input=`L (L _)` `enumerate xs`=pair each element with its index → `[[i, v], ...]`=`L (L _)` `range a b`=half-open numeric range `[a, a+1, ..., b-1]`; empty when `a >= b`=`L n` `map fn xs`=apply `fn` to each element=`L b` `flt fn xs`=keep elements where `fn x` is true=`L a` `ct fn xs`=count elements where `fn x` is true (avoids `len (flt fn xs)`'s intermediate list alloc)=`n` `fld fn xs init`=left fold: `fn (fn (fn init x0) x1) ...`=accumulator `flatmap fn xs`=map then flatten one level=`L b` `mapr fn xs`=map with short-circuit Result propagation: collects Ok values, returns first Err=`R (L b) e` `default-on-err r d`=unwrap `R T E` to `T`, returning `d` if Err; verifier requires `d` matches Ok type. Mirror of `??` for Result (`??` is nil-coalesce for `O T` only - use `default-on-err` for Result). Prefer over `?r{~v:v;^_:d}` when no error payload is needed. ILO-T040 when first arg is not `R T E` (hint steers at `??` only when first arg is Optional); ILO-T042 when the default's type doesn't match the Ok type; ILO-T041 when `??` is used on a Result. T041 is suppressed when the lhs type is `Unknown` (e.g. type-variable params) to avoid false positives on generic code=`T` `partition fn xs`=split list into `[passing, failing]` by predicate=`L (L a)` `chunks n xs`=non-overlapping chunks of size `n` (final chunk may be shorter)=`L (L a)` `window n xs`=sliding windows of size `n` (drops trailing partial; empty if n > len)=`L (L a)` `clamp x lo hi`=restrict `x` to `[lo, hi]` (lower bound wins when `lo > hi`)=`n` `cumsum xs`=running sum; output length matches input=`L n` `cprod xs`=running product; output length matches input=`L n` `frq xs`=frequency map of elements (keys are bare stringified values)=`M t n` `median xs`=median of numeric list=`n` `quantile xs p`=sample quantile (linear interp; `p` clamped to `[0, 1]`)=`n` `stdev xs`=sample standard deviation (divides by N-1)=`n` `variance xs`=sample variance (divides by N-1)=`n` `argmax xs`=index of the maximum element (first occurrence wins on ties; errors on empty list)=`n` `argmin xs`=index of the minimum element (first occurrence wins on ties; errors on empty list)=`n` `argsort xs`=sorted-index permutation ascending - stable sort, indices of smallest to largest (empty list returns `[]`)=`L n` `setunion a b`=set union of two lists (deduped, sorted output)=`L a` `setinter a b`=set intersection (deduped, sorted)=`L a` `setdiff a b`=set difference `a - b` (deduped, sorted)=`L a` `chars s`=explode a string into single-char strings (one per Unicode scalar)=`L t` `ord s`=Unicode codepoint of the first character of `s`=`n` `chr n`=single-character string for codepoint `n`=`t` `upr s`=uppercase (ASCII)=`t` `lwr s`=lowercase (ASCII)=`t` `cap s`=capitalise first char (ASCII)=`t` `padl s w`=left-pad to width `w` with spaces (no-op if already wider)=`t` `padr s w`=right-pad to width `w` with spaces (no-op if already wider)=`t` `padl s w pc`=left-pad to width `w` with 1-character string `pc` (e.g. `"0"` for sortable zero-padded keys)=`t` `padr s w pc`=right-pad to width `w` with 1-character string `pc` (e.g. `"."` for dot-leader alignment)=`t` `rgxall pat s`=every regex match as `L (L t)` (no-group: each match in a 1-elem list)=`L (L t)` `rgxall1 pat s`=flat first-capture-group convenience: 0 groups → `L t` of whole matches; 1 group → `L t` of capture-1 strings; 2+ groups errors=`L t` `rgxall-multi pats s`=multi-pattern flat-match: apply each pattern in `pats:L t` to `s`, concat all hits in pattern order; per-pattern semantics follow `rgxall1` (0 groups → whole matches; 1 group → capture-1 strings; 2+ groups errors)=`L t` `rgxsub pat repl s`=regex substitute all matches; `$1`, `$2`, ... reference capture groups=`t` `dtfmt epoch fmt`=format Unix epoch as text (strftime, UTC)=`R t t` `dtparse s fmt`=parse text to Unix epoch (strftime, UTC)=`R n t` `dtparse-rel s now`=parse relative-date phrase to epoch; `now` is the anchor epoch=`R n t` `dur-parse s`=parse human duration string ("3h 30m", "1 week 2 days", "1.5 hours", "90s") into seconds. Lenient: accepts abbreviations `s`/`m`/`h`/`d`/`w`, full names (singular + plural), decimal quantities, mixed sequences. Err if empty or no unit found=`R n t` `dur-fmt n`=format seconds as human-readable duration ("2h 42m", "1 day", "30s"). Drops zero parts; uses largest applicable units. Zero returns "0s". Negative values format with a leading "-"=`t` `rdjl path`=read JSONL file as `L (R _ t)`: one parse result per non-empty line=`L (R _ t)` `get-many urls`=concurrent HTTP GET fan-out (max 10 parallel), preserves order=`L (R t t)` `sleep ms`=pause current engine for `ms` milliseconds; returns nil=`_` `rou n`=round to nearest integer (banker's rounding)=`n` `rndn mu sigma`=one sample from normal distribution `N(mu, sigma)` (Box-Muller)=`n` `pow b e`=`b` raised to power `e`=`n` `sqrt n`=square root=`n` `exp n`=natural exponent `e^n`=`n` `log n`=natural logarithm=`n` `log10 n`=base-10 logarithm=`n` `log2 n`=base-2 logarithm=`n` `sin n`=sine (radians)=`n` `cos n`=cosine (radians)=`n` `tan n`=tangent (radians)=`n` `asin n`=arcsine, returns radians in `[-pi/2, pi/2]`; NaN outside `[-1, 1]`=`n` `acos n`=arccosine, returns radians in `[0, pi]`; NaN outside `[-1, 1]`=`n` `atan n`=arctangent, returns radians in `[-pi/2, pi/2]`=`n` `atan2 y x`=two-argument arctangent (y, x order; radians)=`n` `pi`=3.141592653589793 (IEEE-754 f64, `f64::consts::PI`)=`n` `tau`=6.283185307179586 (== 2\*pi; one full turn in radians)=`n` `e`=2.718281828459045 (Euler's number, `f64::consts::E`)=`n` `transpose m`=transpose row-major matrix=`L (L n)` `matmul a b`=matrix product=`L (L n)` `dot a b`=vector dot product=`n` `solve a b`=solve `Ax = b` via LU with partial pivoting; errors on singular/non-square=`L n` `inv a`=matrix inverse; errors on singular/non-square=`L (L n)` `det a`=determinant; errors on non-square=`n` `fft xs`=discrete FFT: real samples → `L [re, im]`; zero-padded to next power of 2=`L (L n)` `ifft pairs`=inverse FFT; imaginary part dropped on return=`L n` `fmt2 x digits`=format number `x` to `digits` decimal places (half-to-even rounding; `digits` clamped to `0..=20`). Compose with `fmt` for template + precision: `fmt "x={}" (fmt2 v 2)`=`t` > **`fmt` does not print.** `fmt` and `fmt2` are pure-functional string builders, not `println!`. A bare `fmt "..." v` statement evaluates and discards the resulting text on every engine - nothing reaches stdout. Print with `prnt fmt "..." v` or capture with `line = fmt "..." v`. The verifier emits **ILO-T032** when `fmt`/`fmt2` is a non-tail statement with no binding. Tail position is fine: `say-x v:n>t;fmt "x={}" v` returns the string to the caller as documented. > **`+=`, `mset`, and `mdel` return a new value, they do not mutate in place.** `+=xs v` returns a new list; `mset m k v` and `mdel m k` return a new map. As a bare statement (`@i 0..3{+=out i}`, `mset m "a" 1;m`) the result is silently discarded and the source binding is unchanged. The verifier emits **ILO-T033** when these calls appear at a discarded position - any non-tail statement, or anywhere inside a loop body. Fix is the assignment form: `out=+=out i`, `m=mset m k v`, `m=mdel m k`. Tail position in a function/`?{}` arm is fine - the value flows out as the return. > **`wr` and `wrl` return the written path, not a status.** Both succeed with `~path` (the file path you passed in), not `~"ok"` or nil. A `save` helper that ends with a bare `wrl "tasks.txt" xs` therefore returns `~"tasks.txt"`, and every successful mutation echoes the state-file path to stdout - noise for any caller piping output. Discard the path and return a clean status string instead: `save xs:L t>R t t;r=wrl "tasks.txt" xs;?r{~_:~"ok";^e:^e}`. The error arm still propagates `wrl`'s message. See [`examples/cli-tasks-save-ok.ilo`](examples/cli-tasks-save-ok.ilo) for the full shape. [Datetime (`dtfmt` / `dtparse` / `dtparse-rel`)] UTC only. Format strings follow strftime conventions (`%Y-%m-%d %H:%M:%S`, `%s`, etc). dtfmt 1700000000 "%Y-%m-%d" -- R t t: Ok="2023-11-14", Err if out of range dtparse "2024-01-15" "%Y-%m-%d" -- R n t: Ok=epoch seconds, Err if unparseable dtfmt! e "%H:%M:%S" -- auto-unwrap inside R-returning fn `dtparse-rel s now` resolves a natural-language relative-date phrase to a Unix epoch anchored at `now`. Phrases supported: `today`, `yesterday`, `tomorrow` `N days ago`, `in N days` (also `N day ago`, `in N day`) `N weeks ago`, `in N weeks` `N months ago`, `in N months` (end-of-month clamping: `Jan 31 + 1 month = Feb 28/29`) `last `, `next `, `this ` — weekdays as `monday`–`sunday` or short `mon`–`sun`; `last`/`next` never return today ISO-8601 date literal `YYYY-MM-DD` — passthrough to `dtparse` (ignores `now`) -- now = 1705276800 (2024-01-15, Monday) dtparse-rel!! "yesterday" (now) -- 2024-01-14 00:00 UTC dtparse-rel!! "3 days ago" (now) -- 2024-01-12 00:00 UTC dtparse-rel!! "in 2 weeks" (now) -- 2024-01-29 00:00 UTC dtparse-rel!! "last friday" (now) -- 2024-01-12 00:00 UTC dtparse-rel!! "next wednesday" (now) -- 2024-01-17 00:00 UTC dtparse-rel!! "2023-12-25" (now) -- 1703462400 (ignores now) Unrecognised phrases return `Err` with a message listing valid forms. All times are midnight UTC. [Duration (`dur-parse` / `dur-fmt`)] `dur-parse s > R n t` — parse a human-readable duration string into total seconds as a float. `dur-fmt n > t` — format seconds as a human-readable duration string. Both are tree-bridge eligible: VM and Cranelift dispatch through the same interpreter arm. Accepted units for `dur-parse`: `w`=week, weeks `d`=day, days `h`=hour, hours, hr, hrs `m`=min, mins, minute, minutes `s`=sec, secs, second, seconds dur-parse "3h 30m" -- R n t: Ok=12600, Err if no unit found dur-parse "1 week 2 days" -- R n t: Ok=777600 dur-parse "1.5 hours" -- R n t: Ok=5400 dur-parse "4h32m" -- no space between number and unit: Ok=16320 dur-parse! s -- auto-unwrap inside R-returning fn dur-fmt 9720 -- "2h 42m" dur-fmt 86400 -- "1 day" dur-fmt 90 -- "1m 30s" dur-fmt 90.5 -- "1m 30.5s" (fractional seconds preserved) dur-fmt 0 -- "0s" dur-fmt -90 -- "-1m 30s" (single leading minus) -- Round-trip: parse -> seconds -> format n = dur-parse! "2 days 3 hours" dur-fmt n -- "2 days 3h" **Months are not supported.** `mo`, `month`, `months`, `M` are deliberately omitted because a month is not a fixed number of seconds. Strings like `"3mo"` or `"3 months"` produce a `no recognised unit` error. Use explicit day counts (e.g. `"30 days"`, `"90 days"`). **Sticky sign.** A leading `-` in `dur-parse` is sticky: it applies to every following token until an explicit `+` resets it. So `"-1m 30s"` parses to `-90`, and `"-1h +10m"` parses to `-3000`. This makes the round-trip `dur-fmt -> dur-parse` symmetric for negative durations, where `dur-fmt` emits a single leading minus rather than signing each part. **Fractional seconds.** `dur-fmt` renders sub-second fractions with up to 3 decimal places (trailing zeros stripped), both for sub-second inputs (`0.5 -> "0.5s"`) and for mixed values where the seconds component carries a fraction (`90.5 -> "1m 30.5s"`). Fractional minutes / hours / days / weeks are decomposed into smaller units before formatting. [Set operations] `setunion`, `setinter`, `setdiff` operate on lists of `t`, `n`, or `b` (same constraint as `uniqby`). Output is deduped and sorted by a type-prefixed string key, so results are deterministic across runs and engines. Sort is lexicographic on the key, not numeric - re-sort with `srt` afterwards if you need numeric order. [Linear algebra] `transpose`, `matmul`, `dot`, `solve`, `inv`, `det` operate on row-major matrices (`L (L n)`) and flat vectors (`L n`). `solve`, `inv`, `det` use LU decomposition with partial pivoting and raise on singular or non-square inputs. These ship as host-vetted builtins because hand-rolled implementations risk silent precision loss. [FFT] `fft xs` runs an iterative Cooley-Tukey radix-2 transform on real samples, zero-padding to the next power of two. Output is `L [re, im]` with one inner pair per frequency bin. `ifft pairs` is the inverse, dropping the imaginary part on return. [Builtin aliases] All builtins accept one or more alias names that resolve to the canonical name after parsing. Using an alias triggers a hint suggesting the canonical form. Most aliases go from a familiar long form (e.g. `length`) to the canonical short (`len`), letting newcomers write readable code while learning the canonical names. A small number go the other direction: where the canonical name is already 4+ characters and there is a natural short form with no plausible-user-binding collision, the short form is carved out as a permanent ergonomic alias. `floor`=→=`flr` `ceil`=→=`cel` `round`=→=`rou` `rand`=→=`rnd` `random`=→=`rnd` `rng`=→=`range` `lset`=→=`lst` `regex_all`=→=`rgxall` `regex_sub`=→=`rgxsub` `string`=→=`str` `number`=→=`num` `length`=→=`len` `head`=→=`hd` `tail`=→=`tl` `reverse`=→=`rev` `sort`=→=`srt` `slice`=→=`slc` `unique`=→=`unq` `filter`=→=`flt` `fold`=→=`fld` `flatten`=→=`flat` `concat`=→=`cat` `contains`=→=`has` `group`=→=`grp` `average`=→=`avg` `print`=→=`prnt` `trim`=→=`trm` `split`=→=`spl` `format`=→=`fmt` `regex`=→=`rgx` `read`=→=`rd` `readlines`=→=`rdl` `readbuf`=→=`rdb` `write`=→=`wr` `writelines`=→=`wrl` length xs -- works, but emits: hint: `length` → `len` (canonical form) len xs -- canonical - no hint rng 0 10 -- works, but emits: hint: `rng` → `range` (canonical form) range 0 10 -- canonical - no hint Every alias - both short-form (`rng`, `rand`) and long-form (`head`, `length`, `filter`, `concat`, ...) - follows the same shadow-prevention rule as canonical builtins: using an alias name as a binding LHS or user-function name is rejected at parse time with `ILO-P011`. The alias resolver rewrites call-position uses to the canonical builtin, so if the bind were allowed the user variable would be silently bypassed and the builtin called instead. For example, `head=fmt "### {}" t` then `cat [head body] "\n"` would rewrite `head` in call position to `hd`, emitting empty output with no error. The parser intercepts every alias in all three positions (top-level binding, local binding inside a function, user function declaration) with a rename hint. The full alias table is listed above; every entry triggers `ILO-P011` in all three contexts. `get` and `pst` return `Ok(body)` on success, `Err(message)` on failure (connection error, timeout, DNS failure, etc). In 0.12.0 the `$` sigil was rebound from `get` (parochial — `$` for HTTP is unique to ilo) to the new `run` builtin (argv-list process spawn). `$` for shell-exec reads cross-language — bash, Perl, Ruby, Python, PowerShell, and Zx all use `$` for command substitution. HTTP `get` is still called by name; the `$` shortcut is for process exec only. `post` was renamed to `pst` to bring it into line with the I/O compression family (`rd`, `wr`, `srt`, `flt`, `fld`, `fmt`). get url -- R t t: Ok=response body, Err=error message get! url -- auto-unwrap: Ok→body, Err→propagate to caller pst url body -- R t t: HTTP POST with text body pst url body headers -- R t t: HTTP POST with body and custom headers -- Custom headers: build an M t t map with mmap/mset h=mmap h=mset h "x-api-key" "secret" r=get url h -- GET with x-api-key header r=pst url body h -- POST with x-api-key header -- Explicit timeouts (milliseconds; rounds up to nearest second internally) r=get-to url 5000 -- GET with 5 s timeout; Err if exceeded r=pst-to url body 3000 -- POST with 3 s timeout Behind the `http` feature flag (on by default). Without the feature, `get`/`pst`/`get-to`/`pst-to` return `Err("http feature not enabled")`. [Process spawn] ilo provides one process-spawn primitive: `run cmd argv > R (M t t) t`. The signature is deliberately narrow: the first argument is the program (text), the second is the argv list (`L t`), and the result is a `Result` whose `Ok` carries a three-key Map of stdout / stderr / code as text. r=run "echo" ["hi"] -- Ok({"stdout":"hi\n","stderr":"","code":"0"}) out=mget r.! "stdout" -- "hi\n" $"git" ["status", "--short"] -- equivalent: $ is the sigil shortcut for run **No shell, no interpolation, no glob.** The argv list is passed directly to `std::process::Command::args`. There is no `sh -c`, no string concatenation between `cmd` and `argv`, and no glob expansion. This is the principled defence against shell injection: ilo refuses to provide an injection vector while still providing controlled exec. Compared to bash + `jq`, the argv-list discipline and the typed Result + Map handle make `run` materially safer for agent orchestration. **Non-zero exit is NOT an error.** `Err` is reserved for spawn failures (command not found, permission denied, kernel-level pipe failure, output cap exceeded). A child that returns a non-zero exit code surfaces as `Ok({"stdout":..., "stderr":..., "code":""})`; the caller inspects `code` and branches as needed. This matches Python's `subprocess.run` semantics. **Inherits parent env + cwd.** The first version provides no env or cwd override. Set the parent env / cwd before invoking ilo if you need a different shape. **Captured output is capped at 10 MiB per stream.** Either stream exceeding the cap returns an `Err` rather than partial capture so downstream JSON pipelines never see a truncated payload. **Stdin for child processes.** `run` spawns children with stdin closed (previously `/dev/null`). Use `rdin` / `rdinl` to read the **parent** program's own stdin from the shell pipeline. `rdin` reads all of stdin as text; `rdinl` reads it line by line. Behind the same default build profile as `get`/`pst`; on `wasm32` targets, `run` returns `Err("run: process spawn not available on wasm")`. `env` reads an environment variable by name, returning `Ok(value)` or `Err("env var 'KEY' not set")`: env key -- R t t: Ok=value, Err=not set message env! key -- auto-unwrap: Ok→value, Err→propagate to caller `env-all` returns the full process environment as a `M t t` map wrapped in `R`, mirroring the `env` shape so `env-all!` auto-unwraps inside a Result-returning function. Use it for "merge env over config" patterns where the agent does not know which keys to read up-front: env-all -- R (M t t) t: Ok=map of every env var, Err reserved for future failures env-all! -- auto-unwrap to M t t Non-UTF-8 environment variables are silently skipped (same policy as Rust's `std::env::vars`); the snapshot is always `Ok` today. [JSON builtins] `jpth` extracts a value from a JSON string by dot-separated path. Array elements are accessed by numeric index. **Note: `jpth` is dot-path only, not JSONPath.** A leading `$`, `*` wildcard, or `[...]` bracket selector triggers a diagnostic error pointing at the dot-path form; iterate arrays yourself with `@i` or `map` if you need wildcard behaviour. Since 0.12.1 the Ok variant is **typed**: a JSON array comes back as a list (`@`-iterable, `len`-able), a JSON object comes back as a record (`jdmp`-roundtrippable, `jkeys`-enumerable), and scalars come back as the matching ilo primitive (number, text, bool, nil). Pre-0.12.1 every non-string leaf was stringified, forcing a re-parse via `jpar` to iterate. The signature is now `R _ t`. jpth json "name" -- R _ t: Ok=typed value, Err=error message jpth json "user.name" -- nested path lookup jpth json "items.0.name" -- array index access (dot before index, not [0]) jpth json "spans" -- Ok=L _ when the leaf is a JSON array (iterable!) jpth json "deps" -- Ok=record when the leaf is a JSON object jpth json "n" -- Ok=Number 42 (not Text "42") on a numeric leaf jpth! json "name" -- auto-unwrap jpth json "$.a.b" -- ^"jpth is dot-path only ..." (JSONPath rejected) jpth json "items.*.name" -- ^"jpth is dot-path only ..." (no wildcards) `jkeys json path` returns the **sorted** top-level keys of the JSON object at the dot-path as `L t`. Empty path means root. Errs if the value at the path is not an object. Pairs with `mkeys` (which works on ilo `M` maps) so an agent can enumerate JSON object keys without re-parsing through `jpar`. jkeys json "" -- R (L t) t: Ok=sorted root keys jkeys json "deps" -- sorted keys of the "deps" object jkeys! json "deps" -- auto-unwrap jkeys json "items" -- ^"jkeys: value at path is not a JSON object" `jdmp` serialises any ilo value to a JSON string: jdmp 42 -- "42" jdmp "hello" -- "\"hello\"" jdmp [1 2 3] -- "[1,2,3]" jdmp (pt x:1 y:2) -- "{\"x\":1,\"y\":2}" `jpar` parses a JSON string into ilo values. JSON objects become records with type name `json`, arrays become lists, strings/numbers/bools/null map directly: jpar text -- R _ t: Ok=parsed value, Err=parse error r=jpar! "{\"x\":1}" -- r is a json record, access with r.x `jpar-list` is a typed companion: it parses the JSON string and **asserts the top-level value is an array**. The result is `R (L _) t`, so `jpar-list! body` unwraps directly to a list that `@` can iterate — no intermediate binding or type annotation needed: jpar-list text -- R (L _) t: Ok=list of parsed values, Err=parse or type error -- iterate a JSON array response body: @x (jpar-list! body){prnt x} -- or bind first: xs=jpar-list! body;@i 0..len xs{prnt (at xs i)} Use `jpar` when the JSON top-level shape is unknown (object, array, scalar). Use `jpar-list` when you know the response is an array and want to iterate it immediately. +BUILTINS: Called like functions, compiled to dedicated opcodes. `len x`=length of string (bytes) or list (elements)=`n` `str n`=number to text (integers format without `.0`)=`t` `num t`=text to number; trims leading/trailing ASCII whitespace before parsing (Err if unparseable)=`R n t` `abs n`=absolute value=`n` `min a b`=minimum of two numbers=`n` `min xs`=minimum element of a numeric list (error if empty)=`n` `max a b`=maximum of two numbers=`n` `max xs`=maximum element of a numeric list (error if empty)=`n` `mod a b`=C-style signed remainder; result sign matches dividend. Errors on zero divisor. For negative inputs use `fmod`.=`n` `fmod a b`=Floor-mod: always non-negative when `b > 0`. Equivalent to Python `a % b`. Errors on zero divisor. NaN/Inf inputs propagate via IEEE 754 (same policy as every other math builtin). Use instead of `(a % b + b) % b` workarounds for weekday/timezone arithmetic.=`n` `flr n`=floor (round toward negative infinity)=`n` `cel n`=ceiling (round toward positive infinity)=`n` `rnd`=random float in [0, 1). NOT round - for round use `rou` (alias: `round`). Aliases: `rand`, `random`.=`n` `rnd a b`=random integer in [a, b] (inclusive)=`n` `now`=current Unix timestamp (seconds)=`n` `now-ms`=current Unix timestamp (milliseconds)=`n` `get url`=HTTP GET=`R t t` `get url headers`=HTTP GET with custom headers (`M t t` map)=`R t t` `get-to url timeout-ms`=HTTP GET with explicit timeout (milliseconds); Err if deadline exceeded=`R t t` `pst url body`=HTTP POST with text body (renamed from `post` in 0.12.0)=`R t t` `pst url body headers`=HTTP POST with body and custom headers (`M t t` map)=`R t t` `pst-to url body timeout-ms`=HTTP POST with explicit timeout (milliseconds); Err if deadline exceeded=`R t t` `run cmd argv`=spawn `cmd` with argv list — see [Process spawn](#process-spawn) for the no-shell-no-glob security model=`R (M t t) t` `env key`=read environment variable=`R t t` `env-all`=snapshot the full process environment as `M t t`=`R (M t t) t` `rd path`=read file; format auto-detected from extension (`.csv`/`.tsv`→grid, `.json`→graph, else text)=`R _ t` `rd path fmt`=read file with explicit format override (`"csv"`, `"tsv"`, `"json"`, `"raw"`)=`R _ t` `rdl path`=read file as list of lines=`R (L t) t` `rdin`=read all of stdin as text; Err on I/O failure or WASM=`R t t` `rdinl`=read stdin as list of lines (newlines stripped); Err on I/O failure or WASM=`R (L t) t` `lsd dir`=list directory entries (filenames only, not full paths; sorted lexicographically; includes both files and subdirs; empty dirs return `[]`, not Err). Renamed from `ls` in 0.12.1 so the natural `ls=rdl! p` binding for "lines" stays free.=`R (L t) t` `walk dir`=recursive depth-first traversal; paths returned relative to `dir`, sorted; includes both file and directory entries; symlinks not followed. Unreadable subdirectories (e.g. permission denied) are silently skipped so one locked sibling does not poison the whole walk; an unreadable root still returns `Err`=`R (L t) t` `glob dir pat`=shell-style filter under `dir`: `*`/`?`/`[abc]` within a path segment, `**` across segments; relative-path output, sorted; no matches returns `[]` (not Err). Shares `walk`'s traversal so unreadable subdirectories are skipped silently=`R (L t) t` `dirname path`=POSIX-style parent directory. `dirname "/a/b/c.txt"` → `"/a/b"`, `dirname "/"` → `"/"`, `dirname "foo.txt"` → `""` (POSIX returns `"."` here; ilo returns `""` so `pathjoin [dirname p basename p]` round-trips a plain filename without a phantom `./` prefix), `dirname "foo/"` → `""` (trailing slash stripped, then no directory component remains), `dirname "/a"` → `"/"`. Pure text op, no I/O, no Result. Unix forward-slash semantics; Windows separator handling is a 0.13.0 concern=`t` `basename path`=POSIX-style final path segment. `basename "/a/b/c.txt"` → `"c.txt"`, `basename "/"` → `"/"`, `basename "foo/"` → `"foo"` (trailing slash stripped), `basename ""` → `""`. Pure text op, total=`t` `pathjoin parts`=join a list of path segments with `/`, collapsing duplicate separators at joints and dropping empty segments. `pathjoin ["a" "b" "c.txt"]` → `"a/b/c.txt"`, `pathjoin ["a/" "/b/" "c.txt"]` → `"a/b/c.txt"`, `pathjoin []` → `""`, `pathjoin ["/" "a"]` → `"/a"` (leading absolute root preserved). List form (not variadic) so arity inference stays predictable; matches `cat xs sep`'s shape=`t` `fsize path`=file size in bytes (follows symlinks); `Err` on missing, permission-denied, or path-is-directory. Paired predicate `isfile` collapses the error tier into `false` for one-token branches=`R n t` `mtime path`=last modification time as Unix epoch seconds (`f64`, fractional preserved; follows symlinks); `Err` on missing or permission-denied. Pairs with `now` for "is this file older than N seconds" checks=`R n t` `isfile path`=`true` iff `path` resolves to a regular file (follows symlinks). Missing, permission-denied, or non-file all collapse to `false` — natural shape for `?isfile p{…}`. Asymmetric vs `fsize`/`mtime` (which return `R n t`) by design: predicates want a one-token branch, size/mtime callers want to distinguish missing from perm-denied=`b` `isdir path`=`true` iff `path` resolves to a directory (follows symlinks). Same `false`-on-failure collapse as `isfile`=`b` `rdb s fmt`=parse string/buffer in given format - for data from HTTP, env vars, etc.=`R _ t` `wr path s`=write text to file (overwrite)=`R t t` `wr path data "csv"`=write list-of-lists as CSV (with proper quoting)=`R t t` `wr path data "tsv"`=write list-of-lists as TSV=`R t t` `wr path data "json"`=write any value as pretty JSON=`R t t` `wra path s`=append text to file (create if missing)=`R t t` `wrl path xs`=write list of lines to file (joins with `\n`)=`R t t` `trm s`=trim leading and trailing whitespace=`t` `spl t sep`=split text by separator=`L t` `fmt tmpl args…`=format string - bare `{}` placeholders only, filled left-to-right. Printf-style specs (`{:06d}`, `{:.3f}`) are rejected; compose `fmt2` for decimal precision and `padl` for width/padding. Literal templates require `{}`-count == arg-count (verifier rejects mismatches with `ILO-T013`). Lists are formatted as a single value, not splatted: `fmt "{} {}" [a, b]` is an error - use `fmt "{} {}" a b` instead=`t` `cat xs sep`=join list of text with separator=`t` `has xs v`=membership test (list: element, text: substring)=`b` `hd xs`=head (first element/char) of list or text=element / `t` `tl xs`=tail (all but first) of list or text=`L` / `t` `rev xs`=reverse list or text=same type `srt xs`=sort list (all-number or all-text) or text chars=same type `srt fn xs`=sort list by key function (returns number or text key)=`L` `unq xs`=remove duplicates, preserve order (list or text chars)=same type `slc xs a b`=slice list or text from index a to b (a, b accept negative indices counting from end; bounds clamp)=same type `jpth json path`=JSON dot-path lookup, dot-separated keys + numeric array indices (e.g. `"a.b.0.c"`), not JSONPath - leading `$`, `*`, or `[...]` rejected with a diagnostic. Result is typed: arrays → list, objects → record, scalars → matching primitive.=`R _ t` `jkeys json path`=sorted top-level keys of the JSON object at `path` (empty path = root). Err if the value at the path is not an object.=`R (L t) t` `jdmp value`=serialise ilo value to JSON text=`t` `prnt value`=print value to stdout, return it unchanged (passthrough)=same type `jpar text`=parse JSON text into ilo values=`R _ t` `jpar-list text`=parse JSON text, assert top-level is array, return typed list=`R (L _) t` `grp fn xs`=group list by key function=`M t (L a)` `flat xs`=flatten one level of nesting=`L a` `sum xs`=sum of numeric list (0 for empty)=`n` `prod xs`=product of numeric list (1 for empty)=`n` `avg xs`=mean of numeric list (error if empty)=`n` `rgx pat s`=regex: no groups→all matches; groups→first match captures=`L t` `mmap`=create empty map=`M t _` `mget m k`=value at key k (nil if missing)=element or nil `mset m k v`=new map with key k set to v=`M k v` `mhas m k`=true if key exists=`b` `mkeys m`=sorted list of keys=`L t` `mvals m`=values sorted by key=`L v` `mpairs m`=sorted [k, v] pairs; `mpairs m == zip (mkeys m) (mvals m)`=`L (L _)` `mdel m k`=new map with key k removed=`M k v` `mget-or m k default`=value at key k, or `default` if missing (never nil; default type must match value type)=`v` `at xs i`=i-th element of list or text (0-indexed; negative counts from end; float `i` auto-floors)=element `lget-or xs i default`=element at index `i`, or `default` if OOB (negative indices like `at`; never errors on OOB)=`a` `lst xs i v`=new list with index `i` set to `v` (list update; alias: `lset`)=`L a` `take n xs`=first `n` elements/chars of list or text (n>=0 truncates if n>len; n<0 keeps all but the last `abs n`, Python `xs[:n]`)=same type `drop n xs`=skip first `n` elements/chars (n>=0 returns the rest; n<0 keeps only the last `abs n`, Python `xs[n:]`)=same type `rsrt xs`=sort descending (list or text chars)=same type `rsrt fn xs`=sort descending by key function (returns number or text key)=`L` `rsrt fn ctx xs`=sort descending by key function with explicit ctx arg (closure-bind alternative; `fn` takes `(elem, ctx)`)=`L` `uniqby fn xs`=dedupe by key function (first occurrence wins)=`L a` `zip xs ys`=pairwise pairs of two lists; truncates to shorter input=`L (L _)` `enumerate xs`=pair each element with its index → `[[i, v], ...]`=`L (L _)` `range a b`=half-open numeric range `[a, a+1, ..., b-1]`; empty when `a >= b`=`L n` `map fn xs`=apply `fn` to each element=`L b` `flt fn xs`=keep elements where `fn x` is true=`L a` `ct fn xs`=count elements where `fn x` is true (avoids `len (flt fn xs)`'s intermediate list alloc)=`n` `fld fn xs init`=left fold: `fn (fn (fn init x0) x1) ...`=accumulator `flatmap fn xs`=map then flatten one level=`L b` `mapr fn xs`=map with short-circuit Result propagation: collects Ok values, returns first Err=`R (L b) e` `default-on-err r d`=unwrap `R T E` to `T`, returning `d` if Err; verifier requires `d` matches Ok type. Mirror of `??` for Result (`??` is nil-coalesce for `O T` only - use `default-on-err` for Result). Prefer over `?r{~v:v;^_:d}` when no error payload is needed. ILO-T040 when first arg is not `R T E` (hint steers at `??` only when first arg is Optional); ILO-T042 when the default's type doesn't match the Ok type; ILO-T041 when `??` is used on a Result. T041 is suppressed when the lhs type is `Unknown` (e.g. type-variable params) to avoid false positives on generic code=`T` `partition fn xs`=split list into `[passing, failing]` by predicate=`L (L a)` `chunks n xs`=non-overlapping chunks of size `n` (final chunk may be shorter)=`L (L a)` `window n xs`=sliding windows of size `n` (drops trailing partial; empty if n > len)=`L (L a)` `clamp x lo hi`=restrict `x` to `[lo, hi]` (lower bound wins when `lo > hi`)=`n` `cumsum xs`=running sum; output length matches input=`L n` `cprod xs`=running product; output length matches input=`L n` `frq xs`=frequency map of elements (keys are bare stringified values)=`M t n` `median xs`=median of numeric list=`n` `quantile xs p`=sample quantile (linear interp; `p` clamped to `[0, 1]`)=`n` `stdev xs`=sample standard deviation (divides by N-1)=`n` `variance xs`=sample variance (divides by N-1)=`n` `argmax xs`=index of the maximum element (first occurrence wins on ties; errors on empty list)=`n` `argmin xs`=index of the minimum element (first occurrence wins on ties; errors on empty list)=`n` `argsort xs`=sorted-index permutation ascending - stable sort, indices of smallest to largest (empty list returns `[]`)=`L n` `setunion a b`=set union of two lists (deduped, sorted output)=`L a` `setinter a b`=set intersection (deduped, sorted)=`L a` `setdiff a b`=set difference `a - b` (deduped, sorted)=`L a` `chars s`=explode a string into single-char strings (one per Unicode scalar)=`L t` `ord s`=Unicode codepoint of the first character of `s`=`n` `chr n`=single-character string for codepoint `n`=`t` `upr s`=uppercase (ASCII)=`t` `lwr s`=lowercase (ASCII)=`t` `cap s`=capitalise first char (ASCII)=`t` `padl s w`=left-pad to width `w` with spaces (no-op if already wider)=`t` `padr s w`=right-pad to width `w` with spaces (no-op if already wider)=`t` `padl s w pc`=left-pad to width `w` with 1-character string `pc` (e.g. `"0"` for sortable zero-padded keys)=`t` `padr s w pc`=right-pad to width `w` with 1-character string `pc` (e.g. `"."` for dot-leader alignment)=`t` `rgxall pat s`=every regex match as `L (L t)` (no-group: each match in a 1-elem list)=`L (L t)` `rgxall1 pat s`=flat first-capture-group convenience: 0 groups → `L t` of whole matches; 1 group → `L t` of capture-1 strings; 2+ groups errors=`L t` `rgxall-multi pats s`=multi-pattern flat-match: apply each pattern in `pats:L t` to `s`, concat all hits in pattern order; per-pattern semantics follow `rgxall1` (0 groups → whole matches; 1 group → capture-1 strings; 2+ groups errors)=`L t` `rgxsub pat repl s`=regex substitute all matches; `$1`, `$2`, ... reference capture groups=`t` `dtfmt epoch fmt`=format Unix epoch as text (strftime, UTC)=`R t t` `dtparse s fmt`=parse text to Unix epoch (strftime, UTC)=`R n t` `dtparse-rel s now`=parse relative-date phrase to epoch; `now` is the anchor epoch=`R n t` `dur-parse s`=parse human duration string ("3h 30m", "1 week 2 days", "1.5 hours", "90s") into seconds. Lenient: accepts abbreviations `s`/`m`/`h`/`d`/`w`, full names (singular + plural), decimal quantities, mixed sequences. Err if empty or no unit found=`R n t` `dur-fmt n`=format seconds as human-readable duration ("2h 42m", "1 day", "30s"). Drops zero parts; uses largest applicable units. Zero returns "0s". Negative values format with a leading "-"=`t` `sha256 s`=SHA-256 hex digest (lowercase) of the UTF-8 bytes of `s`=`t` `hmac-sha256 key body`=HMAC-SHA256 of `body` under `key`; returns lowercase hex. Use for webhook signature verification and API request signing=`t` `base64-enc s`=standard base64 encode (RFC 4648 §4, with `=` padding)=`t` `base64-dec s`=standard base64 decode; Err on invalid input (non-base64 chars)=`R t t` `base64url-enc s`=base64url encode (RFC 4648 §5, no padding, URL-safe `-`/`_` alphabet). Use for JWT header/payload segments=`t` `base64url-dec s`=base64url decode; Err on invalid input=`R t t` `hex-enc bytes`=encode a list of integers 0-255 as lowercase hex string=`t` `hex-dec s`=decode a hex string to a list of byte values (each 0-255). Err on odd-length or non-hex input=`R (L n) t` `ct-eq a b`=constant-time text equality - returns `true` iff `a == b` without leaking timing info via short-circuit. Use when comparing secrets (HMAC digests, tokens)=`b` `rdjl path`=read JSONL file as `L (R _ t)`: one parse result per non-empty line=`L (R _ t)` `get-many urls`=concurrent HTTP GET fan-out (max 10 parallel), preserves order=`L (R t t)` `sleep ms`=pause current engine for `ms` milliseconds; returns nil=`_` `rou n`=round to nearest integer (banker's rounding)=`n` `rndn mu sigma`=one sample from normal distribution `N(mu, sigma)` (Box-Muller)=`n` `pow b e`=`b` raised to power `e`=`n` `sqrt n`=square root=`n` `exp n`=natural exponent `e^n`=`n` `log n`=natural logarithm=`n` `log10 n`=base-10 logarithm=`n` `log2 n`=base-2 logarithm=`n` `sin n`=sine (radians)=`n` `cos n`=cosine (radians)=`n` `tan n`=tangent (radians)=`n` `asin n`=arcsine, returns radians in `[-pi/2, pi/2]`; NaN outside `[-1, 1]`=`n` `acos n`=arccosine, returns radians in `[0, pi]`; NaN outside `[-1, 1]`=`n` `atan n`=arctangent, returns radians in `[-pi/2, pi/2]`=`n` `atan2 y x`=two-argument arctangent (y, x order; radians)=`n` `pi`=3.141592653589793 (IEEE-754 f64, `f64::consts::PI`)=`n` `tau`=6.283185307179586 (== 2\*pi; one full turn in radians)=`n` `e`=2.718281828459045 (Euler's number, `f64::consts::E`)=`n` `transpose m`=transpose row-major matrix=`L (L n)` `matmul a b`=matrix product=`L (L n)` `dot a b`=vector dot product=`n` `solve a b`=solve `Ax = b` via LU with partial pivoting; errors on singular/non-square=`L n` `inv a`=matrix inverse; errors on singular/non-square=`L (L n)` `det a`=determinant; errors on non-square=`n` `fft xs`=discrete FFT: real samples → `L [re, im]`; zero-padded to next power of 2=`L (L n)` `ifft pairs`=inverse FFT; imaginary part dropped on return=`L n` `fmt2 x digits`=format number `x` to `digits` decimal places (half-to-even rounding; `digits` clamped to `0..=20`). Compose with `fmt` for template + precision: `fmt "x={}" (fmt2 v 2)`=`t` > **`fmt` does not print.** `fmt` and `fmt2` are pure-functional string builders, not `println!`. A bare `fmt "..." v` statement evaluates and discards the resulting text on every engine - nothing reaches stdout. Print with `prnt fmt "..." v` or capture with `line = fmt "..." v`. The verifier emits **ILO-T032** when `fmt`/`fmt2` is a non-tail statement with no binding. Tail position is fine: `say-x v:n>t;fmt "x={}" v` returns the string to the caller as documented. > **`+=`, `mset`, and `mdel` return a new value, they do not mutate in place.** `+=xs v` returns a new list; `mset m k v` and `mdel m k` return a new map. As a bare statement (`@i 0..3{+=out i}`, `mset m "a" 1;m`) the result is silently discarded and the source binding is unchanged. The verifier emits **ILO-T033** when these calls appear at a discarded position - any non-tail statement, or anywhere inside a loop body. Fix is the assignment form: `out=+=out i`, `m=mset m k v`, `m=mdel m k`. Tail position in a function/`?{}` arm is fine - the value flows out as the return. > **`wr` and `wrl` return the written path, not a status.** Both succeed with `~path` (the file path you passed in), not `~"ok"` or nil. A `save` helper that ends with a bare `wrl "tasks.txt" xs` therefore returns `~"tasks.txt"`, and every successful mutation echoes the state-file path to stdout - noise for any caller piping output. Discard the path and return a clean status string instead: `save xs:L t>R t t;r=wrl "tasks.txt" xs;?r{~_:~"ok";^e:^e}`. The error arm still propagates `wrl`'s message. See [`examples/cli-tasks-save-ok.ilo`](examples/cli-tasks-save-ok.ilo) for the full shape. [Datetime (`dtfmt` / `dtparse` / `dtparse-rel`)] UTC only. Format strings follow strftime conventions (`%Y-%m-%d %H:%M:%S`, `%s`, etc). dtfmt 1700000000 "%Y-%m-%d" -- R t t: Ok="2023-11-14", Err if out of range dtparse "2024-01-15" "%Y-%m-%d" -- R n t: Ok=epoch seconds, Err if unparseable dtfmt! e "%H:%M:%S" -- auto-unwrap inside R-returning fn `dtparse-rel s now` resolves a natural-language relative-date phrase to a Unix epoch anchored at `now`. Phrases supported: `today`, `yesterday`, `tomorrow` `N days ago`, `in N days` (also `N day ago`, `in N day`) `N weeks ago`, `in N weeks` `N months ago`, `in N months` (end-of-month clamping: `Jan 31 + 1 month = Feb 28/29`) `last `, `next `, `this ` — weekdays as `monday`–`sunday` or short `mon`–`sun`; `last`/`next` never return today ISO-8601 date literal `YYYY-MM-DD` — passthrough to `dtparse` (ignores `now`) -- now = 1705276800 (2024-01-15, Monday) dtparse-rel!! "yesterday" (now) -- 2024-01-14 00:00 UTC dtparse-rel!! "3 days ago" (now) -- 2024-01-12 00:00 UTC dtparse-rel!! "in 2 weeks" (now) -- 2024-01-29 00:00 UTC dtparse-rel!! "last friday" (now) -- 2024-01-12 00:00 UTC dtparse-rel!! "next wednesday" (now) -- 2024-01-17 00:00 UTC dtparse-rel!! "2023-12-25" (now) -- 1703462400 (ignores now) Unrecognised phrases return `Err` with a message listing valid forms. All times are midnight UTC. [Duration (`dur-parse` / `dur-fmt`)] `dur-parse s > R n t` — parse a human-readable duration string into total seconds as a float. `dur-fmt n > t` — format seconds as a human-readable duration string. Both are tree-bridge eligible: VM and Cranelift dispatch through the same interpreter arm. Accepted units for `dur-parse`: `w`=week, weeks `d`=day, days `h`=hour, hours, hr, hrs `m`=min, mins, minute, minutes `s`=sec, secs, second, seconds dur-parse "3h 30m" -- R n t: Ok=12600, Err if no unit found dur-parse "1 week 2 days" -- R n t: Ok=777600 dur-parse "1.5 hours" -- R n t: Ok=5400 dur-parse "4h32m" -- no space between number and unit: Ok=16320 dur-parse! s -- auto-unwrap inside R-returning fn dur-fmt 9720 -- "2h 42m" dur-fmt 86400 -- "1 day" dur-fmt 90 -- "1m 30s" dur-fmt 90.5 -- "1m 30.5s" (fractional seconds preserved) dur-fmt 0 -- "0s" dur-fmt -90 -- "-1m 30s" (single leading minus) -- Round-trip: parse -> seconds -> format n = dur-parse! "2 days 3 hours" dur-fmt n -- "2 days 3h" **Months are not supported.** `mo`, `month`, `months`, `M` are deliberately omitted because a month is not a fixed number of seconds. Strings like `"3mo"` or `"3 months"` produce a `no recognised unit` error. Use explicit day counts (e.g. `"30 days"`, `"90 days"`). **Sticky sign.** A leading `-` in `dur-parse` is sticky: it applies to every following token until an explicit `+` resets it. So `"-1m 30s"` parses to `-90`, and `"-1h +10m"` parses to `-3000`. This makes the round-trip `dur-fmt -> dur-parse` symmetric for negative durations, where `dur-fmt` emits a single leading minus rather than signing each part. **Fractional seconds.** `dur-fmt` renders sub-second fractions with up to 3 decimal places (trailing zeros stripped), both for sub-second inputs (`0.5 -> "0.5s"`) and for mixed values where the seconds component carries a fraction (`90.5 -> "1m 30.5s"`). Fractional minutes / hours / days / weeks are decomposed into smaller units before formatting. [Crypto primitives] `sha256`, `hmac-sha256`, `base64-enc`, `base64-dec`, `base64url-enc`, `base64url-dec`, `hex-enc`, `hex-dec`, `ct-eq` are tree-bridge eligible: they dispatch through the tree interpreter so VM and Cranelift share identical semantics. `sha256 s > t` — SHA-256 hex digest (lowercase) of the UTF-8 bytes of `s`. Input is always treated as text; if you need to hash raw bytes, encode them as a string via `hex-enc` first. `hmac-sha256 key body > t` — HMAC-SHA256 of `body` under `key`, lowercase hex. Both arguments are text. Typical use is webhook signature verification (`ct-eq (hmac-sha256 secret payload) sig`) and API request signing. `base64-enc s > t` / `base64-dec s > R t t` — standard base64 (RFC 4648 §4) with `=` padding. `base64-dec` returns `R t t`; the Ok branch holds the decoded text string (valid UTF-8), the Err branch holds the reason. Non-UTF-8 decoded bytes always Err. `base64url-enc s > t` / `base64url-dec s > R t t` — base64url (RFC 4648 §5): URL-safe `-`/`_` alphabet, no padding. Use for JWT header and payload segments. `hex-enc bytes:L n > t` — encode a list of integers 0-255 as lowercase hex. Each element must be a whole number in `[0, 255]`; ILO-R009 on out-of-range values. `hex-dec s > R (L n) t` — decode a hex string (upper- or lowercase) to a list of byte values (each 0-255). Err on odd-length input or non-hex characters. `ct-eq a:t b:t > b` — constant-time text equality. Returns `true` iff `a == b` without short-circuiting on the first differing byte, preventing timing attacks. Always use `ct-eq` rather than `==` when comparing secrets. -- sha256 sha256 "" -- e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 sha256 "abc" -- ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad len (sha256 "x") -- 64 (always) -- hmac hmac-sha256 "secret" "payload" -- lowercase hex -- base64 round-trip base64-dec! (base64-enc "hello") -- "hello" base64url-dec! (base64url-enc "hello") -- "hello" -- hex round-trip hex-dec! (hex-enc [72, 101, 108, 108, 111]) -- [72, 101, 108, 108, 111] -- webhook verification pattern verify sig:t body:t>b;expected=hmac-sha256 "secret" body;ct-eq expected sig [Set operations] `setunion`, `setinter`, `setdiff` operate on lists of `t`, `n`, or `b` (same constraint as `uniqby`). Output is deduped and sorted by a type-prefixed string key, so results are deterministic across runs and engines. Sort is lexicographic on the key, not numeric - re-sort with `srt` afterwards if you need numeric order. [Linear algebra] `transpose`, `matmul`, `dot`, `solve`, `inv`, `det` operate on row-major matrices (`L (L n)`) and flat vectors (`L n`). `solve`, `inv`, `det` use LU decomposition with partial pivoting and raise on singular or non-square inputs. These ship as host-vetted builtins because hand-rolled implementations risk silent precision loss. [FFT] `fft xs` runs an iterative Cooley-Tukey radix-2 transform on real samples, zero-padding to the next power of two. Output is `L [re, im]` with one inner pair per frequency bin. `ifft pairs` is the inverse, dropping the imaginary part on return. [Builtin aliases] All builtins accept one or more alias names that resolve to the canonical name after parsing. Using an alias triggers a hint suggesting the canonical form. Most aliases go from a familiar long form (e.g. `length`) to the canonical short (`len`), letting newcomers write readable code while learning the canonical names. A small number go the other direction: where the canonical name is already 4+ characters and there is a natural short form with no plausible-user-binding collision, the short form is carved out as a permanent ergonomic alias. `floor`=→=`flr` `ceil`=→=`cel` `round`=→=`rou` `rand`=→=`rnd` `random`=→=`rnd` `rng`=→=`range` `lset`=→=`lst` `regex_all`=→=`rgxall` `regex_sub`=→=`rgxsub` `string`=→=`str` `number`=→=`num` `length`=→=`len` `head`=→=`hd` `tail`=→=`tl` `reverse`=→=`rev` `sort`=→=`srt` `slice`=→=`slc` `unique`=→=`unq` `filter`=→=`flt` `fold`=→=`fld` `flatten`=→=`flat` `concat`=→=`cat` `contains`=→=`has` `group`=→=`grp` `average`=→=`avg` `print`=→=`prnt` `trim`=→=`trm` `split`=→=`spl` `format`=→=`fmt` `regex`=→=`rgx` `read`=→=`rd` `readlines`=→=`rdl` `readbuf`=→=`rdb` `write`=→=`wr` `writelines`=→=`wrl` length xs -- works, but emits: hint: `length` → `len` (canonical form) len xs -- canonical - no hint rng 0 10 -- works, but emits: hint: `rng` → `range` (canonical form) range 0 10 -- canonical - no hint Every alias - both short-form (`rng`, `rand`) and long-form (`head`, `length`, `filter`, `concat`, ...) - follows the same shadow-prevention rule as canonical builtins: using an alias name as a binding LHS or user-function name is rejected at parse time with `ILO-P011`. The alias resolver rewrites call-position uses to the canonical builtin, so if the bind were allowed the user variable would be silently bypassed and the builtin called instead. For example, `head=fmt "### {}" t` then `cat [head body] "\n"` would rewrite `head` in call position to `hd`, emitting empty output with no error. The parser intercepts every alias in all three positions (top-level binding, local binding inside a function, user function declaration) with a rename hint. The full alias table is listed above; every entry triggers `ILO-P011` in all three contexts. `get` and `pst` return `Ok(body)` on success, `Err(message)` on failure (connection error, timeout, DNS failure, etc). In 0.12.0 the `$` sigil was rebound from `get` (parochial — `$` for HTTP is unique to ilo) to the new `run` builtin (argv-list process spawn). `$` for shell-exec reads cross-language — bash, Perl, Ruby, Python, PowerShell, and Zx all use `$` for command substitution. HTTP `get` is still called by name; the `$` shortcut is for process exec only. `post` was renamed to `pst` to bring it into line with the I/O compression family (`rd`, `wr`, `srt`, `flt`, `fld`, `fmt`). get url -- R t t: Ok=response body, Err=error message get! url -- auto-unwrap: Ok→body, Err→propagate to caller pst url body -- R t t: HTTP POST with text body pst url body headers -- R t t: HTTP POST with body and custom headers -- Custom headers: build an M t t map with mmap/mset h=mmap h=mset h "x-api-key" "secret" r=get url h -- GET with x-api-key header r=pst url body h -- POST with x-api-key header -- Explicit timeouts (milliseconds; rounds up to nearest second internally) r=get-to url 5000 -- GET with 5 s timeout; Err if exceeded r=pst-to url body 3000 -- POST with 3 s timeout Behind the `http` feature flag (on by default). Without the feature, `get`/`pst`/`get-to`/`pst-to` return `Err("http feature not enabled")`. [Process spawn] ilo provides one process-spawn primitive: `run cmd argv > R (M t t) t`. The signature is deliberately narrow: the first argument is the program (text), the second is the argv list (`L t`), and the result is a `Result` whose `Ok` carries a three-key Map of stdout / stderr / code as text. r=run "echo" ["hi"] -- Ok({"stdout":"hi\n","stderr":"","code":"0"}) out=mget r.! "stdout" -- "hi\n" $"git" ["status", "--short"] -- equivalent: $ is the sigil shortcut for run **No shell, no interpolation, no glob.** The argv list is passed directly to `std::process::Command::args`. There is no `sh -c`, no string concatenation between `cmd` and `argv`, and no glob expansion. This is the principled defence against shell injection: ilo refuses to provide an injection vector while still providing controlled exec. Compared to bash + `jq`, the argv-list discipline and the typed Result + Map handle make `run` materially safer for agent orchestration. **Non-zero exit is NOT an error.** `Err` is reserved for spawn failures (command not found, permission denied, kernel-level pipe failure, output cap exceeded). A child that returns a non-zero exit code surfaces as `Ok({"stdout":..., "stderr":..., "code":""})`; the caller inspects `code` and branches as needed. This matches Python's `subprocess.run` semantics. **Inherits parent env + cwd.** The first version provides no env or cwd override. Set the parent env / cwd before invoking ilo if you need a different shape. **Captured output is capped at 10 MiB per stream.** Either stream exceeding the cap returns an `Err` rather than partial capture so downstream JSON pipelines never see a truncated payload. **Stdin for child processes.** `run` spawns children with stdin closed (previously `/dev/null`). Use `rdin` / `rdinl` to read the **parent** program's own stdin from the shell pipeline. `rdin` reads all of stdin as text; `rdinl` reads it line by line. Behind the same default build profile as `get`/`pst`; on `wasm32` targets, `run` returns `Err("run: process spawn not available on wasm")`. `env` reads an environment variable by name, returning `Ok(value)` or `Err("env var 'KEY' not set")`: env key -- R t t: Ok=value, Err=not set message env! key -- auto-unwrap: Ok→value, Err→propagate to caller `env-all` returns the full process environment as a `M t t` map wrapped in `R`, mirroring the `env` shape so `env-all!` auto-unwraps inside a Result-returning function. Use it for "merge env over config" patterns where the agent does not know which keys to read up-front: env-all -- R (M t t) t: Ok=map of every env var, Err reserved for future failures env-all! -- auto-unwrap to M t t Non-UTF-8 environment variables are silently skipped (same policy as Rust's `std::env::vars`); the snapshot is always `Ok` today. [JSON builtins] `jpth` extracts a value from a JSON string by dot-separated path. Array elements are accessed by numeric index. **Note: `jpth` is dot-path only, not JSONPath.** A leading `$`, `*` wildcard, or `[...]` bracket selector triggers a diagnostic error pointing at the dot-path form; iterate arrays yourself with `@i` or `map` if you need wildcard behaviour. Since 0.12.1 the Ok variant is **typed**: a JSON array comes back as a list (`@`-iterable, `len`-able), a JSON object comes back as a record (`jdmp`-roundtrippable, `jkeys`-enumerable), and scalars come back as the matching ilo primitive (number, text, bool, nil). Pre-0.12.1 every non-string leaf was stringified, forcing a re-parse via `jpar` to iterate. The signature is now `R _ t`. jpth json "name" -- R _ t: Ok=typed value, Err=error message jpth json "user.name" -- nested path lookup jpth json "items.0.name" -- array index access (dot before index, not [0]) jpth json "spans" -- Ok=L _ when the leaf is a JSON array (iterable!) jpth json "deps" -- Ok=record when the leaf is a JSON object jpth json "n" -- Ok=Number 42 (not Text "42") on a numeric leaf jpth! json "name" -- auto-unwrap jpth json "$.a.b" -- ^"jpth is dot-path only ..." (JSONPath rejected) jpth json "items.*.name" -- ^"jpth is dot-path only ..." (no wildcards) `jkeys json path` returns the **sorted** top-level keys of the JSON object at the dot-path as `L t`. Empty path means root. Errs if the value at the path is not an object. Pairs with `mkeys` (which works on ilo `M` maps) so an agent can enumerate JSON object keys without re-parsing through `jpar`. jkeys json "" -- R (L t) t: Ok=sorted root keys jkeys json "deps" -- sorted keys of the "deps" object jkeys! json "deps" -- auto-unwrap jkeys json "items" -- ^"jkeys: value at path is not a JSON object" `jdmp` serialises any ilo value to a JSON string: jdmp 42 -- "42" jdmp "hello" -- "\"hello\"" jdmp [1 2 3] -- "[1,2,3]" jdmp (pt x:1 y:2) -- "{\"x\":1,\"y\":2}" `jpar` parses a JSON string into ilo values. JSON objects become records with type name `json`, arrays become lists, strings/numbers/bools/null map directly: jpar text -- R _ t: Ok=parsed value, Err=parse error r=jpar! "{\"x\":1}" -- r is a json record, access with r.x `jpar-list` is a typed companion: it parses the JSON string and **asserts the top-level value is an array**. The result is `R (L _) t`, so `jpar-list! body` unwraps directly to a list that `@` can iterate — no intermediate binding or type annotation needed: jpar-list text -- R (L _) t: Ok=list of parsed values, Err=parse or type error -- iterate a JSON array response body: @x (jpar-list! body){prnt x} -- or bind first: xs=jpar-list! body;@i 0..len xs{prnt (at xs i)} Use `jpar` when the JSON top-level shape is unknown (object, array, scalar). Use `jpar-list` when you know the response is an array and want to iterate it immediately. LISTS: xs=[1 2 3] -- space-separated (preferred) xs=[1, 2, 3] -- commas also work mixed=["search" 10] -- heterogeneous lists allowed (type: L _) w="world" words=["hi" w] -- variables work in list literals empty=[] Elements are expressions in brackets, separated by spaces or commas. Variables and expressions are allowed as elements. Lists may contain mixed types (inferred as `L _`). Use with `@` to iterate: @x xs{+x 1} Index by integer literal or variable (dot notation): xs.0 # first element (literal index) xs.2 # third element (literal index) xs.i # i-th element when `i` is a bound variable in scope The variable-index form `xs.i` is sugar for `at xs i` - the parser builds a field-access node and a post-parse desugar pass rewrites it whenever the field identifier resolves to a binding in scope (parameter, let, foreach, range, match-arm). Record field access keeps working: if the identifier is also a declared field on any record type in the program, the rewrite is skipped and the strict `.field` semantics apply. **CLI list arguments:** Pass lists from the command line with commas (brackets also accepted): ilo 'f xs:L n>n;len xs' 1,2,3 → 3 ilo 'f xs:L t>t;xs.0' 'a,b,c' → a STATEMENTS: Guards and conditionals replace `if`/`else if`/`else`. They are flat statements - no nesting, no closing braces to match. There are three forms: **Braceless guard** (`cond expr`): early return - if condition is true, returns the expression from the function. **Braced conditional** (`cond{body}`): conditional execution - if condition is true, body runs but execution continues (no early return). Use `ret` inside the body for explicit early return. **Ternary** (`cond{then}{else}`): value expression - evaluates then or else branch, no early return. Multiple braceless guards chain vertically for guard clauses, keeping indentation depth constant. Match replaces `switch`. There is no fall-through - each arm is independent. The `_` arm is the default catch-all. `x=expr`=bind `cond{body}`=conditional execution: run body if cond true (no early return) `cond expr`=braceless guard: early return expr if cond true `cond{then}{else}`=ternary: evaluate then or else (no early return) `?bool{then}{else}`=bare-bool ternary: `?h{1}{0}` (no early return) `?cond then else`=prefix ternary: `?=x 0 10 20` (no early return) `?h cond a b`=general prefix-ternary keyword: `?h cn "y" "n"` (3 operand atoms after literal `?h`) `!cond{body}`=negated conditional execution (no early return) `!cond expr`=braceless negated guard (early return) `!cond{then}{else}`=negated ternary `?x{arms}`=match named value `?{arms}`=match last result `@v list{body}`=iterate list `@i a..b{body}`=range iteration: i from a (inclusive) to b (exclusive) `ret expr`=early return from function `~expr`=return ok `^expr`=return err `func! args`=call + auto-unwrap Result, propagate Err to caller `func!! args`=call + auto-unwrap Result, abort on Err with exit 1 `wh cond{body}`=while loop `brk` / `brk expr`=exit enclosing loop (optional value) `cnt`=skip to next iteration of enclosing loop `expr>>func`=pipe: pass result as last arg to func MATCH ARMS: `"gold":body`=literal text `42:body`=literal number `~v:body`=ok - bind inner value to `v` `^e:body`=err - bind inner value to `e` `n v:body`=number - branch if value is a number, bind to `v` `t v:body`=text - branch if value is text, bind to `v` `b v:body`=bool - branch if value is a bool, bind to `v` `l v:body`=list - branch if value is a list, bind to `v` `_:body`=wildcard, binds matched subject to `_` Arms separated by `;`. First match wins. **Exhaustiveness.** Matches on closed sum-shaped types must cover every variant or include `_:`. For a `R T E` subject, `~v: + ^e:` is exhaustive on its own - no `_:` wildcard required (verifier rule, mirrors `S`-typed matches). For a `b` (bool) subject, `true: + false:` is exhaustive. For numbers and text, `_:` is required. parse>t;r=num "3.14";?r{~v:str v;^e:e} -- canonical two-arm Result match Zero-arg user functions called bare in a value position auto-expand to a call, so `r=mk` where `mk>R t t;...` makes `r` the Result, not a function reference. In any binding position the name `_` is permitted and binds normally - `~_:body`, `^_:body`, `n _:body` etc. expose the matched inner value to `body` under the name `_`. Bodies that don't reference `_` are unaffected. cls sp:n>t;>=sp 1000 "gold";>=sp 500 "silver";"bronze" [Braceless Guards (Early Return)] When the guard condition is a comparison or logical operator (`>=`, `<=`, `>`, `<`, `=`, `!=`, `&`, `|`) and the body is a single expression, braces are optional. **Braceless guards cause early return from the function:** cls sp:n>t;>=sp 1000 "gold";>=sp 500 "silver";"bronze" Negated braceless guards also work: `!<=n 0 ^"must be positive"`. **Comparison operators always start a guard at statement position.** You cannot use `=`, `<`, `>`, `<=`, `>=` etc. as a standalone return expression - the parser treats them as a guard condition and expects a following return value. To return a comparison result, bind it first: -- WRONG: r=has xs v;=r true -- =r true is parsed as a guard, not a return expression -- OK: r=has xs v;r -- return the bool directly (only safe as the last statement) -- OK: has xs v -- bare call is safe as last statement in last function [Braced Conditionals (No Early Return)] A braced guard `cond{body}` is **conditional execution** - the body runs if the condition is true, but execution always continues to the next statement (no early return): f x:n>n;>x 0{99};+x 1 -- {99} runs when x>0 but is discarded; always returns +x 1 This makes braced conditionals natural in loops: f xs:L n>n;m=0;@x xs{>x m{m=x}};m -- find max: update m when x > m Use `ret` inside a braced conditional for explicit early return: f x:n>n;>x 0{ret x};-x -- return x early if positive, else negate > **Common footgun.** `=cond{val}` reads like "if cond, return val" but it isn't. The braces are conditional execution: `val` is evaluated, discarded, and execution falls through to the next statement. If you want early return, use the braceless form `=cond val` (when val is a single expression) or wrap with `ret` inside the braces: `=cond{ret val}`. > > ``` > f x:n>n;=x 1{99};0 -- f 1 → 0 (99 is discarded, falls through) > f x:n>n;=x 1 99;0 -- f 1 → 99 (braceless guard: early return) > f x:n>n;=x 1{ret 99};0 -- f 1 → 99 (explicit ret inside braces) > ``` [Ternary (Guard-Else)] A guard followed by a second brace block becomes a ternary - it produces a value without early return: f x:n>t;=x 1{"yes"}{"no"} Like braced conditionals, ternary does **not** return from the function. Code after the ternary continues executing: f x:n>n;=x 0{10}{20};+x 1 -- always returns x+1, ternary value is discarded Negated ternary: `!=x 1{"not one"}{"one"}`. **Bare-bool ternary** uses `?` with a bool-valued expression as the condition - no comparison operator required: f h:b>n;?h{1}{0} -- if h then 1 else 0 f x:n>t;c=>x 0;?c{"pos"}{"nonpos"} -- bool from comparison, then ternary This is the natural shape when the condition is already a bool (function param, comparison result, predicate call) and saves the explicit `=h true` step that the `=cond{a}{b}` form would otherwise require. Detected purely by shape: `?subj{a}{b}` where both braces contain a single colon-and-semi-free expression. Match-arm forms (`?x{1:a;2:b;_:c}`, `?h{true:a;false:b}`) are unaffected - the colon or semicolon at the outer brace level routes them to match parsing. **Prefix ternary** uses `?` with a comparison operator for a fully prefix-style conditional: f x:n>n;?=x 0 10 20 -- if x==0 then 10 else 20 f x:n>n;v=?>x 100 1 0;v -- assign result to v The condition must start with a comparison operator (`=`, `>`, `<`, `>=`, `<=`, `!=`). **Bare-bool prefix ternary** uses `?` with a bool-valued subject (param, comparison result, predicate call) followed by two operand atoms - the parens-free, brace-free shape: f h:b>n;?h 1 0 -- if h then 1 else 0 f h:b>n;v=?h 1 0;v -- assign result to v This is the cheapest shape when the condition is already a bool - 6 chars for `?h 1 0` vs 8 for the brace form `?h{1}{0}` and 12 for the eq-prefix form `?=h true 1 0`. The match-vs-ternary disambiguator routes `?subj{arms-with-colon-or-semi}` to match parsing, `?subj{a}{b}` to brace bare-bool ternary, and `?subj a b` (two bare operands at the cursor, no leading brace) to bare-bool prefix ternary. `?subj` alone with no following operand still errors the same way as before. **`?h cond a b` general prefix-ternary keyword** uses the literal subject ident `h` plus three operand atoms - the condition is the first operand and `a`/`b` are the arms, analogous to the `?=`/`?>`/`?<` family of comparison-prefix-ternaries but with the condition as an arbitrary bool-valued atom rather than a comparison expression: f x:n>t;cn=>x 0;?h cn "pos" "nonpos" -- comparison-derived bool as condition f t:t>t;ok=has ["a" "b" "c"] t;?h ok "yes" "no" -- predicate result as condition f mn:t>t;cn=(=mn "v40");sc1=?h cn "v4" "v3";sc1 -- in let-RHS The disambiguator is operand count: **two** operand atoms after `?h` keeps the bool-subject reading above (`?h a b` → `if h then a else b`); **three** operand atoms promotes `?h` to the fixed keyword form (`?h cond a b` → `if cond then a else b`). The keyword reading triggers only for the literal ident `h`, so every other bool-named subject (`?ready a b`, `?ok 1 0`, …) keeps the PR #330 semantics regardless of how many operands follow. Use the keyword form when the condition is a more complex bool expression than a single ref and you want the cheapest prefix shape; the brace form `?cond{a}{b}` works too but is two characters longer per occurrence. Each of the three operand slots accepts the same shapes as a prefix-binop operand - atom, nested prefix operator, or known-arity call. `?h =a b sev sc "NONE"` parses `sev sc` as `Call(sev, [sc])` in the then-slot, so `Call` results don't have to be bound first or paren-grouped (paren form `(sev sc)` still works as an explicit alternative). **Condition must be `b`.** The verifier rejects (`ILO-T038`) any ternary whose cond doesn't type-check to `b` - number, text, function-ref, `R T E` without unwrap, etc. This catches the silent-truthy family of bugs where a non-bool cond would otherwise always take the then-branch at runtime. If the cond is more complex than a single ref or comparison, bind it first (`c=;?h c a b`) or use the brace-delimited ternary `?cond{then}{else}`. The original 0.12.0 bug that motivated this check: `?h (> p 0.5) 1 0` parsed the paren-grouped prefix-comparison as a zero-param inline lambda, lifted it into a synthetic decl, and silently always took the then-branch - both layers (parser disambiguator + verifier type-check) are now hardened against the family. [Early Return] `ret expr` explicitly returns from the current function: f x:n>n;>x 0{ret x};0 -- return x early if positive, else 0 f xs:L n>n;@x xs{>=x 10{ret x}};0 -- return first element >= 10 Braceless guards provide early return for simple cases. Use `ret` inside braced conditionals when you need early return with more complex logic or inside loops. [Range Iteration] `@i a..b{body}` iterates `i` from `a` (inclusive) to `b` (exclusive). Both bounds can be atoms, prefix-op expressions, or function calls. The index variable is a fresh binding per iteration; other variables in the body update the enclosing scope: f>n;s=0;@i 0..5{s=+s i};s -- sum 0+1+2+3+4 = 10 f>n;xs=[];@i 0..3{xs=+=xs i};xs -- [0, 1, 2] f n:n>n;s=0;@i 0..n{s=+s i};s -- dynamic end bound g xs:L n>n;s=0;@j 0..len xs{s=+s j};s -- call-form bound h i:n n:n>L n;xs=[];@j +i 2..n{xs=+=xs j};xs -- prefix-op bound [While Loop] `wh cond{body}` loops while condition is truthy: f>n;i=0;s=0;wh n;i=0;wh true{i=+i 1;>=i 3{ret i}};0 -- ret inside braced guard: early return from loop Variable rebinding inside loops updates the existing variable rather than creating a new binding. [Break and Continue] `brk` exits the enclosing `wh` or `@` loop. `cnt` skips to the next iteration: f>n;i=0;wh true{i=+i 1;>=i 3{brk}};i -- i = 3 f>n;i=0;s=0;wh =i 3{cnt};s=+s i};s -- s = 3 (skips i>=3) `brk expr` provides an optional value (currently discarded - the loop result is the last body value before the break). Both `brk` and `cnt` work inside braced conditionals within loops. Using them outside a loop is a compile-time error (no-op in current implementation). [Pipe Operator] `>>` chains calls by passing the left side as the last argument to the right side: str x>>len -- desugars to: len (str x) add x 1>>add 2 -- desugars to: add 2 (add x 1) f x>>g>>h -- desugars to: h (g (f x)) Pipes desugar at parse time - no new AST node. Works with `!` for auto-unwrap: `f x>>g!>>h`. [Safe Field Navigation] `.?` is the tolerant field accessor. It returns nil whenever the access can't yield a real value, instead of erroring: object is nil → nil object is a present record but the field is missing → nil object is not a record at all (list, text, number) → nil user.?name -- nil if user is nil, else user.name (or nil if absent) user.?addr.?city -- chained: nil propagates through chain x.?name??"unknown" -- combine with ?? for defaults r.?optMetric.?v40 -- heterogeneous JSON (jpar): optional fields stay nil Strict `.field` access still errors on missing fields, so typo detection on user-defined record types survives at verify time (ILO-T019) and at runtime (ILO-R005). Use `.field` when you want the strictness, `.?field` when the field is optional or the record shape is dynamic. [Nil-Coalesce Operator] `??` evaluates the left side; if nil, evaluates and returns the right side: x??42 -- if x is nil, returns 42 a??b??99 -- chained: first non-nil wins, else 99 mk 0??"default" -- works with function results Compiled via `OP_JMPNN` (jump if not nil) - right side is only evaluated when left is nil. Use braces when the body has multiple statements: >=sp 1000{a=classify sp;a} ?r{^e:^+"failed: "e;~v:v} From b27e246825a89331290535e957b12b4d4bacafe2 Mon Sep 17 00:00:00 2001 From: Daniel Morris Date: Wed, 20 May 2026 19:54:42 +0100 Subject: [PATCH 08/10] chore: trim skill token budget for crypto/duration sections Compact the Duration and Crypto sections in ilo-builtins-text.md to stay safely under the 1000-token CI limit. Removed code blocks; content is now inline prose. Local count: 705 tokens (was 972). --- skills/ilo/ilo-builtins-text.md | 22 ++-------------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/skills/ilo/ilo-builtins-text.md b/skills/ilo/ilo-builtins-text.md index 752f31d5..4427d3ac 100644 --- a/skills/ilo/ilo-builtins-text.md +++ b/skills/ilo/ilo-builtins-text.md @@ -54,26 +54,8 @@ last-week-start nw:n>n;dtparse-rel!! "last monday" nw ## Duration -`dur-parse s > R n t` — parse human duration into seconds. Accepts `s/m/h/d/w`, full names (singular + plural), decimals, mixed ("3h 30m", "1.5 hours", "1 week 2 days"). Months unsupported (not fixed length). Leading `-` is sticky: `"-1m 30s"` = -90. Err if empty or no unit found. - -`dur-fmt n > t` — seconds to human-readable. Drops zero parts; largest units. Zero = "0s". Negative emits leading minus. Fractional seconds preserved up to 3dp. - -``` -dur-parse! "3h 30m" -- 12600 -dur-fmt 9720 -- "2h 42m" -dur-fmt -90 -- "-1m 30s" -dur-parse! "-1h 30m" -- -5400 (sticky sign) -``` +`dur-parse s > R n t` parse human duration to seconds (`s/m/h/d/w`, decimals, mixed "3h 30m"). Leading `-` sticky. `dur-fmt n > t` seconds to human-readable; drops zero parts. ## Crypto -`sha256 s > t` SHA-256 lowercase hex. `hmac-sha256 key body > t` HMAC-SHA256 hex (webhook signing, API auth). `base64-enc/dec s > t/R t t` standard base64 with `=` padding. `base64url-enc/dec s > t/R t t` url-safe no-pad (JWT). `hex-enc bytes:L n > t` 0-255 list to hex. `hex-dec s > R (L n) t` hex to byte list. `ct-eq a b > b` constant-time equality — use instead of `==` for secrets. - -``` -sha256 "abc" -- ba7816bf... -hmac-sha256 "key" "payload" -- 64-char hex -base64-dec! (base64-enc "hi") -- "hi" -hex-dec! (hex-enc [255,0,16]) -- [255,0,16] -ct-eq sig expected -- bool, no timing leak --- webhook: verify sig:t body:t>b;ct-eq (hmac-sha256 "secret" body) sig -``` +`sha256 s > t` SHA-256 hex. `hmac-sha256 key body > t` HMAC-SHA256 hex. `base64-enc s > t` / `base64-dec s > R t t` standard (padded). `base64url-enc s > t` / `base64url-dec s > R t t` url-safe no-pad. `hex-enc bytes:L n > t` / `hex-dec s > R (L n) t`. `ct-eq a b > b` constant-time equality (use for secrets, not `==`). From 2b64622fd06f9b9c2c5af1e32d1ff56b1887936a Mon Sep 17 00:00:00 2001 From: Daniel Morris Date: Wed, 20 May 2026 20:53:58 +0100 Subject: [PATCH 09/10] docs: add crypto builtins and recent builtins to SKILL.md reserved names sha256, hmac-sha256, base64-enc/dec, base64url-enc/dec, hex-enc/dec, ct-eq plus get-to, pst-to, jpar-list, dur-parse, dur-fmt, get-many, env-all, rgxall, rgxall-multi, rgxall1, rgxsub, lget-or, mget-or, now-ms, default-on-err. --- skills/ilo/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/ilo/SKILL.md b/skills/ilo/SKILL.md index 9fe72a7b..2da81087 100644 --- a/skills/ilo/SKILL.md +++ b/skills/ilo/SKILL.md @@ -61,7 +61,7 @@ Every builtin name and control-flow keyword is reserved. Using any as a binding **3-char control-flow**: `brk` (break) `cnt` (continue) `ret` (return) -**4-char+**: `acos` `asin` `atan` `argmax` `argmin` `argsort` `basename` `chars` `chunks` `clamp` `cprod` `cumsum` `dirname` `dot` `drop` `dtfmt` `dtparse` `dtparse-rel` `enumerate` `flat` `flatmap` `fmod` `fmt2` `fsize` `glob` `ifft` `inv` `isdir` `isfile` `jdmp` `jkeys` `jpar` `jpth` `len` `mapr` `matmul` `mdel` `median` `mget` `mhas` `mkeys` `mmap` `mpairs` `mset` `mtime` `mvals` `padl` `padr` `partition` `pathjoin` `prnt` `prod` `quantile` `range` `rdb` `rdin` `rdinl` `rdjl` `rdl` `rndn` `rsrt` `setdiff` `setinter` `setunion` `sleep` `solve` `sqrt` `stdev` `take` `transpose` `uniqby` `variance` `walk` `window` `wra` `wrl` +**4-char+**: `acos` `asin` `atan` `argmax` `argmin` `argsort` `basename` `base64-dec` `base64-enc` `base64url-dec` `base64url-enc` `chars` `chunks` `clamp` `cprod` `cumsum` `ct-eq` `default-on-err` `dirname` `dot` `drop` `dtfmt` `dtparse` `dtparse-rel` `dur-fmt` `dur-parse` `enumerate` `env-all` `flat` `flatmap` `fmod` `fmt2` `fsize` `get-many` `get-to` `glob` `hex-dec` `hex-enc` `hmac-sha256` `ifft` `inv` `isdir` `isfile` `jdmp` `jkeys` `jpar` `jpar-list` `jpth` `len` `lget-or` `mapr` `matmul` `mdel` `median` `mget` `mget-or` `mhas` `mkeys` `mmap` `mpairs` `mset` `mtime` `mvals` `now-ms` `padl` `padr` `partition` `pathjoin` `prnt` `prod` `pst-to` `quantile` `range` `rdb` `rdin` `rdinl` `rdjl` `rdl` `rgxall` `rgxall-multi` `rgxall1` `rgxsub` `rndn` `rsrt` `setdiff` `setinter` `setunion` `sha256` `sleep` `solve` `sqrt` `stdev` `take` `transpose` `uniqby` `variance` `walk` `window` `wra` `wrl` ## Compatibility note From c8fa1100dfa13866d3e19170b6291d6505083b11 Mon Sep 17 00:00:00 2001 From: Daniel Morris Date: Wed, 20 May 2026 21:17:32 +0100 Subject: [PATCH 10/10] fix: spawn fibonacci test with explicit stack to avoid overflow in debug builds sha2/hmac debug-build frames are larger than the default 2 MiB test thread stack. The recursive fibonacci test (fib(10)) hits the limit. Spawn with an 8 MiB thread so the test is robust regardless of which crypto deps are compiled alongside it. --- src/interpreter/mod.rs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/interpreter/mod.rs b/src/interpreter/mod.rs index 89b18e73..e0cc8d0d 100644 --- a/src/interpreter/mod.rs +++ b/src/interpreter/mod.rs @@ -8980,11 +8980,17 @@ mod tests { #[test] fn interpret_braceless_guard_fibonacci() { - let source = "fib n:n>n;<=n 1 n;a=fib -n 1;b=fib -n 2;+a b"; - assert_eq!( - run_str(source, Some("fib"), vec![Value::Number(10.0)]), - Value::Number(55.0) - ); + // Run in a thread with an explicit 8 MiB stack. In debug builds the + // sha2/hmac frames push each interpreter call frame past the default + // 2 MiB stack limit when running fib(10) recursively. + let source = "fib n:n>n;<=n 1 n;a=fib -n 1;b=fib -n 2;+a b".to_owned(); + let result = std::thread::Builder::new() + .stack_size(8 * 1024 * 1024) + .spawn(move || run_str(&source, Some("fib"), vec![Value::Number(10.0)])) + .unwrap() + .join() + .unwrap(); + assert_eq!(result, Value::Number(55.0)); } #[test]