on master help sign in
34 files changed+4582−1376
since splitsnapshots
unifiedsplit
Cargo.lock+6 Cargo.toml+1 crates/knot-compile-rt/src/lib.rs+48−84 crates/knot-compiler/Cargo.toml+5 crates/knot-compiler/src/codegen.rs+317−413 crates/knot-compiler/src/infer.rs+379−64 crates/knot-compiler/src/lockfile.rs+64−1 crates/knot-compiler/src/main.rs+42−45 crates/knot-compiler/src/types.rs+331−85 crates/knot-compiler/src/unused.rs+4−1 crates/knot-compiler/tests/compile_panic_containment.rs+51 crates/knot-compiler/tests/descriptor_roundtrip.rs+84 crates/knot-descriptor/Cargo.toml+6 crates/knot-descriptor/src/lib.rs+376 crates/knot-jit/src/lib.rs+151−40 crates/knot-jit/tests/cli_overrides.rs+567−1 crates/knot-jit/tests/jit_e2e.rs+64−15 crates/knot-jit/tests/migration.rs+36−2 crates/knot-jit/tests/persistence.rs+304−223 crates/knot-jit/tests/refinements.rs+155−7 crates/knot-lsp/src/defs.rs+4−1 crates/knot-lsp/src/incremental.rs+4−1 crates/knot-lsp/src/linked_editing.rs+4−2 crates/knot-lsp/src/rename.rs+5−1 crates/knot-lsp/src/semantic_tokens.rs+3−2 crates/knot-lsp/src/shared.rs+7−2 crates/knot-lsp/src/type_format.rs+8−10 crates/knot-runtime/Cargo.toml+1 crates/knot-runtime/src/db_dump.rs+143 crates/knot-runtime/src/lib.rs+1225−210 crates/knot-runtime/src/tui.rs+21−78 crates/knot/src/ast.rs+15−10 crates/knot/src/format.rs+36−12 crates/knot/src/parser.rs+115−66
Cargo.lock+6
@@ -1491,6 +1491,7 @@ dependencies = [
14911491 "gimli",
14921492 "indexmap",
14931493 "knot",
1494+ "knot-descriptor",
14941495 "knot-runtime",
14951496 "libc",
14961497 "object",
@@ -1498,6 +1499,10 @@ dependencies = [
14981499 "z3",
14991500]
15001501
1502+[[package]]
1503+name = "knot-descriptor"
1504+version = "0.1.0"
1505+
15011506[[package]]
15021507name = "knot-jit"
15031508version = "0.1.0"
@@ -1537,6 +1542,7 @@ dependencies = [
15371542 "dashmap",
15381543 "ed25519-dalek",
15391544 "getrandom 0.4.2",
1545+ "knot-descriptor",
15401546 "libc",
15411547 "ratatui",
15421548 "rusqlite",
Cargo.toml+1
@@ -17,6 +17,7 @@ edition = "2024"
1717[workspace.dependencies]
1818libc = "0.2"
1919knot = { path = "crates/knot" }
20+knot-descriptor = { path = "crates/knot-descriptor" }
2021knot-compiler = { path = "crates/knot-compiler" }
2122knot-runtime = { path = "crates/knot-runtime" }
2223knot-jit = { path = "crates/knot-jit" }
crates/knot-compile-rt/src/lib.rs+48−84
@@ -8,11 +8,13 @@
88
99use std::ffi::c_void;
1010
11−/// Adapter bridging the JIT's `c_void`-typed scan signature to the runtime's
12−/// `*mut Value` scan. `c_void` and `Value` are both opaque here, so the cast
13−/// is just a pointer repoint.
14−unsafe extern "C" fn scan_code_free(v: *mut c_void) -> i32 {
15− knot_runtime::knot_value_is_code_free(v.cast())
11+/// Adapter bridging the JIT's `c_void`-typed set-code signature to the
12+/// runtime's `knot_value_set_code`. Both pointer args are opaque to the JIT
13+/// (a `Value*` and a `CodeHandle*`), so the casts are just pointer repoints.
14+unsafe extern "C" fn set_code(v: *mut c_void, handle: *const c_void) {
15+ unsafe {
16+ knot_runtime::knot_value_set_code(v.cast(), handle.cast());
17+ }
1618}
1719
1820/// The compile implementation registered into knot-runtime. Matches the
@@ -45,24 +47,11 @@ pub unsafe extern "C" fn knot_compile_impl(
4547) -> *mut knot_runtime::Value {
4648 // Null out-params up front so a compile error leaves them well-defined.
4749 unsafe {
48− if !out_ty_ptr.is_null() {
49− *out_ty_ptr = std::ptr::null_mut();
50− }
51− if !out_ty_len.is_null() {
52− *out_ty_len = 0;
53− }
54− if !out_rels_ptr.is_null() {
55− *out_rels_ptr = std::ptr::null_mut();
56− }
57− if !out_rels_len.is_null() {
58− *out_rels_len = 0;
59− }
60− if !out_err_ptr.is_null() {
61− *out_err_ptr = std::ptr::null_mut();
62− }
63− if !out_err_len.is_null() {
64− *out_err_len = 0;
65− }
50+ knot_runtime::compile_out_params_init([
51+ (out_ty_ptr, out_ty_len),
52+ (out_rels_ptr, out_rels_len),
53+ (out_err_ptr, out_err_len),
54+ ]);
6655 }
6756
6857 let src_bytes = unsafe { std::slice::from_raw_parts(src_ptr, src_len) };
@@ -79,69 +68,44 @@ pub unsafe extern "C" fn knot_compile_impl(
7968 std::str::from_utf8(b).ok()
8069 };
8170
82− match knot_jit::compile_and_run(
83− source,
84− db,
85− expected,
86− // The runtime's code-free scan lets the JIT free the module when the
87− // result holds no Function/IO (the common, pure-data case).
88− Some(scan_code_free as unsafe extern "C" fn(*mut c_void) -> i32),
89− ) {
90− Ok(cv) => {
91− // Hand the inferred type back as a malloc'd buffer (C ABI — the
92− // runtime frees it). Only when we actually have one.
93− if let Some(ty) = cv.ty
94− && !out_ty_ptr.is_null()
95− && !out_ty_len.is_null()
96− {
97− let buf = unsafe { libc::malloc(ty.len()) as *mut u8 };
98− if !buf.is_null() {
99− unsafe {
100− std::ptr::copy_nonoverlapping(ty.as_ptr(), buf, ty.len());
101− *out_ty_ptr = buf;
102− *out_ty_len = ty.len();
103− }
104− } else {
105− unsafe { libc::free(buf as *mut c_void) };
106− }
107− }
108− // Hand the snippet's declared relations back as a comma-separated
109− // malloc'd buffer, for the runtime's host-relation check.
110− if !out_rels_ptr.is_null() && !out_rels_len.is_null() {
111− let csv = cv.relations.join(",");
112− let buf = unsafe { libc::malloc(csv.len().max(1)) as *mut u8 };
113− if !buf.is_null() {
114− unsafe {
115− std::ptr::copy_nonoverlapping(csv.as_ptr(), buf, csv.len());
116− *out_rels_ptr = buf;
117− *out_rels_len = csv.len();
118− }
119− } else {
120− unsafe { libc::free(buf as *mut c_void) };
121− }
122− }
123− // knot-jit treats the value as opaque *mut c_void; here it's a
124− // knot-runtime Value again.
125− cv.value as *mut knot_runtime::Value
126− }
127− Err(e) => {
128− // Hand the compile-error message back as a malloc'd buffer (C ABI —
129− // the runtime frees it) so `base.compile` can return `Err {error}`.
130− let msg = e.0;
131− if !out_err_ptr.is_null() && !out_err_len.is_null() {
132− let buf = unsafe { libc::malloc(msg.len().max(1)) as *mut u8 };
133− if !buf.is_null() {
134− unsafe {
135− std::ptr::copy_nonoverlapping(msg.as_ptr(), buf, msg.len());
136− *out_err_ptr = buf;
137− *out_err_len = msg.len();
71+ // This is the `knot_compile_impl` a *compiled program* links — the
72+ // `base.compile` boundary that runs in the host's process. Contain any
73+ // panic (in compilation or the JIT'd snippet) so it surfaces as an Err
74+ // instead of unwinding across `extern "C"` into the host. Shared with the
75+ // compiler binary's impl via `run_compile_contained` so the two can't drift.
76+ unsafe {
77+ knot_runtime::run_compile_contained(
78+ db,
79+ out_ty_ptr,
80+ out_ty_len,
81+ out_rels_ptr,
82+ out_rels_len,
83+ out_err_ptr,
84+ out_err_len,
85+ |db| {
86+ // A clean compile error: report it via out_err (as null), which
87+ // the shared helper leaves to the runtime's `Result.Err`. We
88+ // signal it by writing the message here and returning None.
89+ match knot_jit::compile_and_run(
90+ source,
91+ db,
92+ expected,
93+ Some(set_code as unsafe extern "C" fn(*mut c_void, *const c_void)),
94+ ) {
95+ Ok(cv) => Some(knot_runtime::CompileOutcome {
96+ // knot-jit treats the value as opaque *mut c_void; here
97+ // it's a knot-runtime Value again.
98+ value: cv.value as *mut knot_runtime::Value,
99+ ty: cv.ty,
100+ relations: cv.relations,
101+ }),
102+ Err(e) => {
103+ knot_runtime::compile_write_out(&e.0, out_err_ptr, out_err_len);
104+ None
138105 }
139− } else {
140− unsafe { libc::free(buf as *mut c_void) };
141106 }
142− }
143− std::ptr::null_mut()
144− }
107+ },
108+ )
145109 }
146110}
147111
crates/knot-compiler/Cargo.toml+5
@@ -13,6 +13,7 @@ path = "src/main.rs"
1313
1414[dependencies]
1515knot = { workspace = true }
16+knot-descriptor = { workspace = true }
1617knot-runtime = { workspace = true }
1718cranelift-codegen = { workspace = true }
1819cranelift-frontend = { workspace = true }
@@ -40,3 +41,7 @@ cranelift-jit = "0.131"
4041
4142[build-dependencies]
4243blake3 = { version = "1", default-features = false }
44+
45+[dev-dependencies]
46+knot-descriptor = { workspace = true }
47+
crates/knot-compiler/src/codegen.rs+317−413
@@ -198,6 +198,12 @@ pub struct Codegen<M: cranelift_module::Module = ObjectModule> {
198198 // `from` of a pending migration's first step (the baseline it upgrades).
199199 locked_schemas: HashMap<String, String>,
200200
201+ // Every migration step (committed chain + pending), as pure data
202+ // (source, step_idx, from_schema, to_schema, using_src). Registered with the
203+ // runtime in C main's PROLOGUE — before the `db` subcommand dispatch — so
204+ // `db info` can show the full chain without running `knot_user_main`.
205+ migration_steps: Vec<(String, i64, String, String, String)>,
206+
201207 // Fork identity for an uncommitted migration: hash of the pending
202208 // (to-schema, using-src) pairs, or empty when there is no pending
203209 // migration. When set, the runtime routes the DB open to a fork file so
@@ -231,6 +237,11 @@ pub struct Codegen<M: cranelift_module::Module = ObjectModule> {
231237 // Type aliases for resolving response types in OpenAPI descriptors
232238 type_aliases: HashMap<String, ResolvedType>,
233239
240+ // CLI `#`-arg merged defaults: arg name → the concrete literal default any
241+ // scope supplies. A `_` (required) declaration whose name has a concrete
242+ // default elsewhere uses it instead of aborting when the flag is absent.
243+ cli_arg_defaults: HashMap<String, Option<String>>,
244+
234245 // Trampolines for user functions used as values: fn_name -> trampoline_func_id
235246 user_fn_trampolines: HashMap<String, FuncId>,
236247
@@ -674,19 +685,31 @@ fn scalar_override_type(
674685 // unit-agnostic, classify by the base scalar.
675686 name if name.starts_with("Int ") => "Int".to_string(),
676687 name if name.starts_with("Float ") => "Float".to_string(),
677− name if name.starts_with("Rel ") => {
678− let elem = name[4..].trim();
688+ name if name.starts_with("Rel ")
689+ || name.starts_with("Vec ")
690+ || name.starts_with("List ") =>
691+ {
692+ let container = name.split_whitespace().next().unwrap();
693+ let elem = name[container.len()..].trim();
679694 let elem = elem
680695 .strip_prefix('(')
681696 .and_then(|r| r.strip_suffix(')'))
682697 .unwrap_or(elem);
683698 let elem_base = match elem {
684− "Int" | "Float" | "Text" | "Bool" => elem,
685− e if e.starts_with("Int ") => "Int",
686− e if e.starts_with("Float ") => "Float",
699+ "Int" | "Float" | "Text" | "Bool" => elem.to_string(),
700+ "Bytes" => "Bytes".to_string(),
701+ "Uuid" => "Uuid".to_string(),
702+ e if e.starts_with("Int ") => "Int".to_string(),
703+ e if e.starts_with("Float ") => "Float".to_string(),
704+ // A refined element parses as its base scalar; the descriptor
705+ // carries the refined name so the per-element predicate check
706+ // can run. An enum element parses as a constructor name; the
707+ // ctor set is expanded later by `override_runtime_descriptor`.
708+ e if refined_types.contains_key(e) => format!("refined:{e}"),
709+ e if adt_ctors.contains_key(e) => format!("enum:{e}"),
687710 _ => return None,
688711 };
689− format!("Rel {elem_base}")
712+ format!("{container} {elem_base}")
690713 }
691714 name => {
692715 match type_aliases.get(name)? {
@@ -992,6 +1015,7 @@ fn compile_inner<M: cranelift_module::Module>(
9921015 .unwrap_or("knot");
9931016 cg.db_path = format!("{}.db", stem);
9941017 cg.source_schemas = type_env.source_schemas.clone();
1018+ cg.cli_arg_defaults = type_env.cli_arg_defaults.clone();
9951019 for (name, schema) in &type_env.source_schemas {
9961020 if schema.starts_with("_value:") {
9971021 cg.scalar_sources.insert(name.clone());
@@ -1030,6 +1054,42 @@ fn compile_inner<M: cranelift_module::Module>(
10301054 fork_hash(&parts.join(""))
10311055 };
10321056 }
1057+ // Compute the per-source migration step metadata EAGERLY (before any
1058+ // function is emitted), so C main's prologue — built before `knot_user_main`
1059+ // — can register it for `db info`. Mirrors the step assembly in the
1060+ // migration emission loop, minus the parsed fn (which only that loop needs).
1061+ {
1062+ let pending = crate::lockfile::pending_migrations(program);
1063+ let mut rels: Vec<String> = cg.migrate_chains.keys().cloned().collect();
1064+ for (r, _) in &pending {
1065+ if !rels.contains(r) {
1066+ rels.push(r.clone());
1067+ }
1068+ }
1069+ rels.sort();
1070+ for relation in rels {
1071+ let mut steps: Vec<(i64, String, String, String)> = Vec::new();
1072+ if let Some(chain) = cg.migrate_chains.get(&relation) {
1073+ for step in chain {
1074+ steps.push((
1075+ steps.len() as i64,
1076+ step.from_schema.clone(),
1077+ step.to_schema.clone(),
1078+ step.using_src.clone(),
1079+ ));
1080+ }
1081+ }
1082+ if let Some((_, p)) = pending.iter().find(|(r, _)| r.as_str() == relation.as_str()) {
1083+ let from = cg.locked_schemas.get(&relation).cloned().unwrap_or_default();
1084+ let to = cg.pending_targets.get(&relation).cloned().unwrap_or_default();
1085+ let using = knot::format::render_expr_source(&p.using_fn);
1086+ steps.push((steps.len() as i64, from, to, using));
1087+ }
1088+ for (idx, from, to, using) in steps {
1089+ cg.migration_steps.push((relation.clone(), idx, from, to, using));
1090+ }
1091+ }
1092+ }
10331093 // Sources the codebase no longer declares but the schema lockfile still
10341094 // tracks: emit a startup DROP for each so removed relations don't linger.
10351095 cg.dropped_sources =
@@ -1479,7 +1539,7 @@ pub fn jit_compile_typed(source: &str, expected: Option<&str>) -> Option<JitComp
14791539 return None;
14801540 }
14811541 crate::desugar::desugar(&mut program);
1482− let type_env = crate::types::TypeEnv::from_program(&program);
1542+ let mut type_env = crate::types::TypeEnv::from_program(&program);
14831543 let crate::infer::CheckOutput {
14841544 diagnostics: infer_diags,
14851545 monad_info,
@@ -1527,6 +1587,9 @@ pub fn jit_compile_typed(source: &str, expected: Option<&str>) -> Option<JitComp
15271587 if crate::infer::take_subsumption_verdict() == Some(false) {
15281588 return None;
15291589 }
1590+ // Inference passed: safe to derive schemas (an unpersistable or
1591+ // dangling field type was already rejected above with a clean error).
1592+ type_env.compute_schemas(&program);
15301593 let overrides = HashMap::new();
15311594 let compile_expected_types = crate::infer::CompileExpectedTypes::new();
15321595 let cg = compile_with(
@@ -1625,29 +1688,6 @@ pub fn eval_pure_to_bool(source: &str) -> Option<bool> {
16251688 .unwrap_or_default()
16261689}
16271690
1628−/// One step in a path from a `base.compile` result's top-level value down to a
1629−/// value that must satisfy a host refined-type predicate.
1630−#[derive(Clone, Debug, PartialEq, Eq)]
1631−enum RefinePathStep {
1632− /// Descend into a record field (records carry their named fields;
1633− /// constructor payloads delegate to `.value` first).
1634− RecordField(String),
1635− /// The value is a relation (`[T]`): every element continues along the
1636− /// remaining path.
1637− Relation,
1638− /// The value is a `Maybe`: `Nothing` carries no refined scalar (skip),
1639− /// `Just` unwraps to its payload and continues.
1640− Maybe,
1641−}
1642−
1643−/// A leaf of a `base.compile` expected type that is a host-refined type,
1644−/// paired with the path that reaches a value of that type at runtime.
1645−#[derive(Clone, Debug)]
1646−struct RefinePath {
1647− refined_name: String,
1648− steps: Vec<RefinePathStep>,
1649−}
1650−
16511691impl<M: cranelift_module::Module> Codegen<M> {
16521692 /// Build a `Codegen` around an already-constructed module. Backend-agnostic:
16531693 /// the ISA/unwind setup is the same for `ObjectModule` (AOT) and `JITModule`.
@@ -1687,6 +1727,7 @@ impl<M: cranelift_module::Module> Codegen<M> {
16871727 migrate_chains: HashMap::new(),
16881728 pending_targets: HashMap::new(),
16891729 locked_schemas: HashMap::new(),
1730+ migration_steps: Vec::new(),
16901731 fork_token: String::new(),
16911732 dropped_sources: Vec::new(),
16921733 diagnostics: Vec::new(),
@@ -1695,6 +1736,7 @@ impl<M: cranelift_module::Module> Codegen<M> {
16951736 route_entries: HashMap::new(),
16961737 fetch_route_entries: HashMap::new(),
16971738 type_aliases: HashMap::new(),
1739+ cli_arg_defaults: HashMap::new(),
16981740 user_fn_trampolines: HashMap::new(),
16991741 capture_env_in_progress: std::collections::HashSet::new(),
17001742 relational_do_spans: HashSet::new(),
@@ -1892,6 +1934,7 @@ impl<M: cranelift_module::Module> Codegen<M> {
18921934 // failure in any one source rolls all of them back.
18931935 self.declare_rt("knot_migrate_group_begin", &[p], &[]);
18941936 self.declare_rt("knot_migrate_group_end", &[p], &[]);
1937+ self.declare_rt("knot_register_migration_step", &[p, p, types::I64, p, p, p, p, p, p], &[]);
18951938 // Runs knot_user_main under a catch_unwind boundary on the main thread.
18961939 self.declare_rt("knot_run_user_main", &[p, p], &[p]);
18971940 // Runs the whole program (knot_program_main) on a worker thread under
@@ -1911,9 +1954,11 @@ impl<M: cranelift_module::Module> Codegen<M> {
19111954
19121955 // CLI constant overrides
19131956 self.declare_rt("knot_override_lookup", &[p, p, types::I32], &[p]);
1957+ self.declare_rt("knot_override_forbid", &[p, p], &[p]);
19141958 self.declare_rt("knot_override_lookup_ex", &[p, p, p, p], &[p]);
19151959 self.declare_rt("knot_override_unwrap_just", &[p], &[p]);
19161960 self.declare_rt("knot_override_check_refined", &[p, p, p, p, p, p], &[]);
1961+ self.declare_rt("knot_override_check_refined_each", &[p, p, p, p, p, p, p], &[]);
19171962 self.declare_rt("knot_override_check_help", &[p, p], &[]);
19181963
19191964 // STM tracking
@@ -2203,7 +2248,7 @@ impl<M: cranelift_module::Module> Codegen<M> {
22032248 self.declare_rt("knot_api_register", &[p, p, p], &[]);
22042249 self.declare_rt("knot_api_handle", &[types::I32, p], &[types::I32]);
22052250
2206− // DB explorer TUI
2251+ // DB explorer TUI + binary dump/restore (`db dump`/`db restore`)
22072252 self.declare_rt("knot_db_handle", &[types::I32, p, p, p], &[types::I32]);
22082253 self.declare_rt("knot_dump_handle", &[types::I32, p, p, p], &[types::I32]);
22092254 self.declare_rt("knot_schema_source_set", &[p, p], &[]);
@@ -4599,6 +4644,23 @@ impl<M: cranelift_module::Module> Codegen<M> {
45994644 cg.call_rt_void(builder, "knot_schema_source_set", &[sptr, slen]);
46004645 }
46014646
4647+ // Register the baked-in migration chain with the runtime here, in
4648+ // the prologue — BEFORE the `db` subcommand dispatch — so `db info`
4649+ // can show the full chain without running `knot_user_main`.
4650+ let steps = cg.migration_steps.clone();
4651+ for (src_name, step_idx, from, to, using_src) in steps {
4652+ let (nptr, nlen) = cg.string_ptr(builder, &src_name);
4653+ let (fptr, flen) = cg.string_ptr(builder, &from);
4654+ let (tptr, tlen) = cg.string_ptr(builder, &to);
4655+ let (uptr, ulen) = cg.string_ptr(builder, &using_src);
4656+ let idx = builder.ins().iconst(types::I64, step_idx);
4657+ cg.call_rt_void(
4658+ builder,
4659+ "knot_register_migration_step",
4660+ &[nptr, nlen, idx, fptr, flen, tptr, tlen, uptr, ulen],
4661+ );
4662+ }
4663+
46024664 // Register route tables for the api command
46034665 let mut route_tables: Vec<(Value, Vec<ast::RouteEntry>)> = Vec::new();
46044666 for (route_name, entries) in &all_routes {
@@ -4881,9 +4943,11 @@ impl<M: cranelift_module::Module> Codegen<M> {
48814943 }
48824944
48834945 for relation in relations {
4884− // Assemble (old_schema, new_schema, using_fn) steps: committed
4885− // chain first, then the pending step (if any).
4886− let mut steps: Vec<(String, String, ast::Expr)> = Vec::new();
4946+ // Assemble (old_schema, new_schema, using_fn, using_src) steps:
4947+ // committed chain first, then the pending step (if any).
4948+ // `using_src` is the raw migration-fn text, registered so
4949+ // `db info` can show the full chain.
4950+ let mut steps: Vec<(String, String, ast::Expr, String)> = Vec::new();
48874951 if let Some(chain) = migrate_chains.get(&relation) {
48884952 for step in chain {
48894953 match parse_migration_fn(&step.using_src) {
@@ -4891,6 +4955,7 @@ impl<M: cranelift_module::Module> Codegen<M> {
48914955 step.from_schema.clone(),
48924956 step.to_schema.clone(),
48934957 f,
4958+ step.using_src.clone(),
48944959 )),
48954960 None => {
48964961 cg.diagnostics.push(knot::diagnostic::Diagnostic::error(
@@ -4915,10 +4980,12 @@ impl<M: cranelift_module::Module> Codegen<M> {
49154980 .cloned()
49164981 .unwrap_or_default();
49174982 let to = pending_targets.get(&relation).cloned().unwrap_or_default();
4918− steps.push((from, to, using_ast.clone()));
4983+ let sp = using_ast.span;
4984+ let src = cg.source_text[sp.start..sp.end].to_string();
4985+ steps.push((from, to, using_ast.clone(), src));
49194986 }
49204987
4921− for (step_idx, (old_schema, new_schema, using_fn)) in steps.iter().enumerate() {
4988+ for (step_idx, (old_schema, new_schema, using_fn, _using_src)) in steps.iter().enumerate() {
49224989 let (name_ptr, name_len) = cg.string_ptr(builder, &relation);
49234990 let (old_ptr, old_len) = cg.string_ptr(builder, &old_schema);
49244991 let (new_ptr, new_len) = cg.string_ptr(builder, &new_schema);
@@ -6587,6 +6654,28 @@ ast::ExprKind::Atomic(inner) => {
65876654 );
65886655 }
65896656
6657+ // A collection of a refined element (`Vec refined:Pos`,
6658+ // `Rel refined:Pos`, `List refined:Pos`, optionally
6659+ // `Maybe`-wrapped): run the predicate per element.
6660+ let coll_refined = ["Vec refined:", "Rel refined:", "List refined:"]
6661+ .iter()
6662+ .find_map(|p| {
6663+ type_str
6664+ .strip_prefix(p)
6665+ .or_else(|| type_str.strip_prefix(&format!("Maybe {p}")))
6666+ });
6667+ if let Some(refined_name) = coll_refined {
6668+ let refined_name = refined_name.to_string();
6669+ self.emit_refined_collection_override_check(
6670+ builder,
6671+ name,
6672+ &refined_name,
6673+ override_val,
6674+ env,
6675+ db,
6676+ );
6677+ }
6678+
65906679 match default {
65916680 Some(d) => {
65926681 // Optional: use override if present, else the default.
@@ -6616,30 +6705,82 @@ ast::ExprKind::Atomic(inner) => {
66166705 builder.block_params(merge_block)[0]
66176706 }
66186707 None => {
6619− // Required: abort if the flag is missing.
6620− let missing_block = builder.create_block();
6621− let ok_block = builder.create_block();
6622− let is_null =
6623− builder.ins().icmp_imm(IntCC::Equal, override_val, 0);
6624− builder
6625− .ins()
6626− .brif(is_null, missing_block, &[], ok_block, &[]);
6627− builder.switch_to_block(missing_block);
6628− builder.seal_block(missing_block);
6629− let (msg_ptr, msg_len) = self.string_ptr(
6630− builder,
6631− &format!("missing required argument --{}", name),
6632− );
6633− let _ = self.call_rt(builder, "knot_todo", &[msg_ptr, msg_len]);
6634− builder.ins().jump(ok_block, &[]);
6635− builder.switch_to_block(ok_block);
6636− builder.seal_block(ok_block);
6637− override_val
6708+ // Required — UNLESS another scope declared this same
6709+ // `#name` with a concrete default (the agreement check
6710+ // merged it). Then the flag-or-default logic applies:
6711+ // the flag wins, else the merged default.
6712+ let merged_default = self
6713+ .cli_arg_defaults
6714+ .get(name.as_str())
6715+ .and_then(|d| d.as_ref())
6716+ .cloned();
6717+ match merged_default {
6718+ Some(lit_src) => {
6719+ // Parse the merged literal default into an
6720+ // expr and use the optional-arg default path.
6721+ let lexer = knot::lexer::Lexer::new(&lit_src);
6722+ let (tokens, _) = lexer.tokenize();
6723+ let parser =
6724+ knot::parser::Parser::new(lit_src.clone(), tokens);
6725+ let (dexpr, _) = parser.parse_file_expr();
6726+ let default_block = builder.create_block();
6727+ let override_block = builder.create_block();
6728+ let merge_block = builder.create_block();
6729+ builder.append_block_param(merge_block, self.ptr_type);
6730+ let is_null = builder
6731+ .ins()
6732+ .icmp_imm(IntCC::Equal, override_val, 0);
6733+ builder
6734+ .ins()
6735+ .brif(is_null, default_block, &[], override_block, &[]);
6736+
6737+ builder.switch_to_block(override_block);
6738+ builder.seal_block(override_block);
6739+ builder.ins().jump(merge_block, &[override_val.into()]);
6740+
6741+ builder.switch_to_block(default_block);
6742+ builder.seal_block(default_block);
6743+ let default_val =
6744+ self.compile_expr(builder, &dexpr, env, db);
6745+ builder.ins().jump(merge_block, &[default_val.into()]);
6746+
6747+ builder.switch_to_block(merge_block);
6748+ builder.seal_block(merge_block);
6749+ builder.block_params(merge_block)[0]
6750+ }
6751+ None => {
6752+ // Truly required: abort if the flag is missing.
6753+ let missing_block = builder.create_block();
6754+ let ok_block = builder.create_block();
6755+ let is_null = builder
6756+ .ins()
6757+ .icmp_imm(IntCC::Equal, override_val, 0);
6758+ builder
6759+ .ins()
6760+ .brif(is_null, missing_block, &[], ok_block, &[]);
6761+ builder.switch_to_block(missing_block);
6762+ builder.seal_block(missing_block);
6763+ let (msg_ptr, msg_len) = self.string_ptr(
6764+ builder,
6765+ &format!("missing required argument --{}", name),
6766+ );
6767+ let _ =
6768+ self.call_rt(builder, "knot_todo", &[msg_ptr, msg_len]);
6769+ builder.ins().jump(ok_block, &[]);
6770+ builder.switch_to_block(ok_block);
6771+ builder.seal_block(ok_block);
6772+ override_val
6773+ }
6774+ }
66386775 }
66396776 }
66406777 } else {
6641− // Non-overridable type — no CLI parser. Fall through to the
6642− // default (or error for required args).
6778+ // Non-overridable type — no CLI parser. Abort loudly if the
6779+ // flag was passed (rather than silently using the default),
6780+ // then fall through to the default (or error for required
6781+ // args).
6782+ let (name_ptr, name_len) = self.string_ptr(builder, name.as_str());
6783+ self.call_rt(builder, "knot_override_forbid", &[name_ptr, name_len]);
66436784 match default {
66446785 Some(d) => self.compile_expr(builder, d.as_ref(), env, db),
66456786 None => {
@@ -7534,24 +7675,7 @@ ast::ExprKind::Atomic(inner) => {
75347675 {
75357676 let src = self.compile_arg_expr(builder, args[0], env, db);
75367677 let (eptr, elen) = self.string_ptr(builder, &expected);
7537− let result =
7538− self.call_rt(builder, "knot_builtin_compile_typed", &[src, eptr, elen]);
7539− // Option B — host-side refinement gate. The JIT's expected-type
7540− // subsumption knows refined types only by NAME (their
7541− // predicates never cross the wire), so a snippet could
7542− // redeclare `type Port = Int where \_ -> True` and slip a value
7543− // past the name check. Validate the returned value against the
7544− // host's own predicate chain at EVERY host-refined occurrence
7545− // in the expected type — top-level or nested in a record,
7546− // relation, or `Maybe` — swapping `Ok` → refinement `Err` on
7547− // any violation.
7548− let paths = self.collect_refine_paths(&expected);
7549− if !paths.is_empty() {
7550− return self.emit_compile_result_refinement_check_paths(
7551− builder, result, &paths, env, db,
7552− );
7553− }
7554− return result;
7678+ return self.call_rt(builder, "knot_builtin_compile_typed", &[src, eptr, elen]);
75557679 }
75567680 }
75577681
@@ -13512,6 +13636,22 @@ ast::ExprKind::Atomic(inner) => {
1351213636 {
1351313637 return format!("Maybe {expanded}");
1351413638 }
13639+ // A collection element (`Vec enum:Mode`, `Rel refined:Pos`, …): expand
13640+ // the element descriptor, keeping the container prefix.
13641+ for container in ["Vec ", "Rel ", "List "] {
13642+ if let Some(rest) = type_str.strip_prefix(container)
13643+ && let Some(expanded) = expand(rest)
13644+ {
13645+ return format!("{container}{expanded}");
13646+ }
13647+ if let Some(rest) = type_str
13648+ .strip_prefix("Maybe ")
13649+ .and_then(|r| r.strip_prefix(container))
13650+ && let Some(expanded) = expand(rest)
13651+ {
13652+ return format!("Maybe {container}{expanded}");
13653+ }
13654+ }
1351513655 type_str.to_string()
1351613656 }
1351713657
@@ -13655,300 +13795,63 @@ ast::ExprKind::Atomic(inner) => {
1365513795 builder.seal_block(done_block);
1365613796 }
1365713797
13658− /// Collect the conjoined predicate chain for a host-refined type
13659− /// (`type Age = Nat where …` checks `Nat`'s predicates too). Walks the AST
13660− /// alias map (`alias_ast`): a refined alias's base is the AST type it
13661− /// wraps, so stepping through `TypeKind::Named` bases visits each stacked
13662− /// refinement. Empty when `type_name` is not (or does not resolve through)
13663− /// a host refinement.
13664− fn refined_predicate_chain(&self, type_name: &str) -> Vec<ast::Expr> {
13798+ /// Emit the per-element refined check for a collection override
13799+ /// (`Vec`/`Rel`/`List refined:Pos`). Compiles the predicate chain and calls
13800+ /// the runtime iterator, which applies it to each element and aborts on the
13801+ /// first failure. No-op when the flag is absent (null) — the default was
13802+ /// checked at compile time.
13803+ fn emit_refined_collection_override_check(
13804+ &mut self,
13805+ builder: &mut FunctionBuilder,
13806+ flag_name: &str,
13807+ type_name: &str,
13808+ override_val: Value,
13809+ env: &mut Env,
13810+ db: Value,
13811+ ) {
13812+ // Collect the predicate chain (stacked refinements conjoin), as in
13813+ // `emit_refined_override_check`.
1366513814 let mut preds: Vec<ast::Expr> = Vec::new();
1366613815 let mut cur: String = type_name.to_string();
1366713816 loop {
13668− let Some(pred) = self.refined_types.get(&cur) else {
13669− break;
13670− };
13817+ let Some(pred) = self.refined_types.get(&cur) else { break };
1367113818 preds.push(pred.clone());
13672− let Some(ast_ty) = self.alias_ast.get(&cur) else {
13673− break;
13674− };
13819+ let Some(ast_ty) = self.alias_ast.get(&cur) else { break };
1367513820 let ast::TypeKind::Refined { base, .. } = &ast_ty.node else {
1367613821 break;
1367713822 };
13678− let ast::TypeKind::Named(n) = &base.node else {
13679− break;
13680− };
13823+ let ast::TypeKind::Named(n) = &base.node else { break };
1368113824 cur = n.clone();
1368213825 }
13683− preds
13684− }
13685−
13686− /// Collect the host-refined occurrences in an expected-type string as
13687− /// validation paths. The string is rendered by `display_ty_wire` so it
13688− /// re-parses via `parse_type_str`; the resulting `Type` AST is walked for
13689− /// `Named` leaves the host registered as refined, recording how to reach
13690− /// each at runtime. Anything not statically resolvable to a refined scalar
13691− /// — function types, non-refined ADTs, type variables, opaque structures —
13692− /// yields no path (the value is accepted unchecked there).
13693− fn collect_refine_paths(&self, expected: &str) -> Vec<RefinePath> {
13694− let Some(ast_ty) = knot::parser::parse_type_str(expected) else {
13695− return Vec::new();
13696− };
13697− let mut out = Vec::new();
13698− let mut steps = Vec::new();
13699− let mut depth = 0usize;
13700− self.walk_refine_paths(&ast_ty, &mut steps, &mut out, &mut depth);
13701− out
13702− }
13703−
13704− /// Walk a parsed expected type, accumulating `steps` to each host-refined
13705− /// leaf. `depth` bounds recursion so a pathological (deeply nested or
13706− /// cyclic-alias) type cannot overflow the stack; beyond it the walker
13707− /// conservatively stops (that subtree is unchecked).
13708− fn walk_refine_paths(
13709− &self,
13710− ty: &ast::Type,
13711− steps: &mut Vec<RefinePathStep>,
13712− out: &mut Vec<RefinePath>,
13713− depth: &mut usize,
13714− ) {
13715− const MAX_DEPTH: usize = 32;
13716− if *depth >= MAX_DEPTH {
13826+ if preds.is_empty() {
1371713827 return;
1371813828 }
13719− match &ty.node {
13720− ast::TypeKind::Named(n) => {
13721− if self.refined_types.contains_key(n) {
13722− out.push(RefinePath {
13723− refined_name: n.clone(),
13724− steps: steps.clone(),
13725− });
13726− }
13727− // A non-refined named type is either a primitive, an alias the
13728− // wire already resolved, or an ADT/record name — none of which
13729− // the walker can statically descend into here.
13730− }
13731− ast::TypeKind::Record { fields, .. } => {
13732− for f in fields {
13733− let name = f.name.clone();
13734− if name.is_empty() {
13735− continue;
13736− }
13737− steps.push(RefinePathStep::RecordField(name));
13738− *depth += 1;
13739− self.walk_refine_paths(&f.value, steps, out, depth);
13740− *depth -= 1;
13741− steps.pop();
13742− }
13743− }
13744− ast::TypeKind::Relation(inner) => {
13745− steps.push(RefinePathStep::Relation);
13746− *depth += 1;
13747− self.walk_refine_paths(inner, steps, out, depth);
13748− *depth -= 1;
13749− steps.pop();
13750− }
13751− // `Maybe x`: the wrapped value is `Nothing {}` (skip) or
13752− // `Just {value}` (continue). Only the single-argument form is a
13753− // Maybe; anything else is an opaque application.
13754− ast::TypeKind::App { func, arg }
13755− if matches!(&func.node, ast::TypeKind::Named(n) if n == "Maybe") =>
13756− {
13757− steps.push(RefinePathStep::Maybe);
13758− *depth += 1;
13759− self.walk_refine_paths(arg, steps, out, depth);
13760− *depth -= 1;
13761− steps.pop();
13762− }
13763− // `IO x` / `Assoc n x`-style single wrappers the wire renders as an
13764− // application: descend so a refined leaf under them is still found.
13765− ast::TypeKind::App { func, arg }
13766− if matches!(&func.node, ast::TypeKind::Named(n) if n == "IO") =>
13767− {
13768− *depth += 1;
13769− self.walk_refine_paths(arg, steps, out, depth);
13770− *depth -= 1;
13771− }
13772− // A refined type used INLINE in an annotation (`Nat \x -> …`) has no
13773− // host registry entry by name; only named host refinements are
13774− // gated, so there is nothing to collect from a `Refined` node.
13775− _ => {}
13776− }
13777− }
13778−
13779− /// Walk the runtime value `val` along a refinement path, folding the
13780− /// all-predicates-passed flag `ok` (an I32 0/1). At each refined leaf the
13781− /// type's predicate chain runs against the value; intermediate steps
13782− /// navigate the value: record fields are extracted, `Just` is unwrapped
13783− /// (`Nothing` skips), and a relation loops over every element. Returns the
13784− /// AND of `ok` with every predicate verdict reached along the path.
13785− fn emit_refine_walk(
13786− &mut self,
13787− builder: &mut FunctionBuilder,
13788− val: Value,
13789− steps: &[RefinePathStep],
13790− refined_name: &str,
13791− ok: Value,
13792− env: &mut Env,
13793− db: Value,
13794− ) -> Value {
13795− match steps.split_first() {
13796− // Leaf: run the conjoined predicate chain against `val`.
13797− None => {
13798− let preds = self.refined_predicate_chain(refined_name);
13799− let mut acc = ok;
13800− for pred in &preds {
13801− let pred_fn = self.compile_expr(builder, pred, env, db);
13802− let res = self.call_rt(builder, "knot_value_call", &[db, pred_fn, val]);
13803− let pred_ok =
13804− self.call_rt_typed(builder, "knot_value_get_bool", &[res], types::I32);
13805− acc = builder.ins().band(acc, pred_ok);
13806− }
13807− acc
13808− }
13809− Some((RefinePathStep::RecordField(name), rest)) => {
13810− let (p, l) = self.string_ptr(builder, name);
13811− let field = self.call_rt(builder, "knot_record_field", &[val, p, l]);
13812− self.emit_refine_walk(builder, field, rest, refined_name, ok, env, db)
13813− }
13814− Some((RefinePathStep::Maybe, rest)) => {
13815− // `Nothing {}` carries no refined scalar — skip (pass `ok`
13816− // through). `Just {value}` unwraps to its payload, which the
13817− // rest of the path checks. Both arms merge the resulting flag.
13818− let unwrapped = self.call_rt(builder, "knot_override_unwrap_just", &[val]);
13819− let present = builder.ins().icmp_imm(IntCC::NotEqual, unwrapped, 0);
13820− let run_block = builder.create_block();
13821− let done_block = builder.create_block();
13822− builder.append_block_param(done_block, types::I32);
13823− builder.ins().brif(present, run_block, &[], done_block, &[ok.into()]);
13824− builder.switch_to_block(run_block);
13825− builder.seal_block(run_block);
13826− let checked =
13827− self.emit_refine_walk(builder, unwrapped, rest, refined_name, ok, env, db);
13828− builder.ins().jump(done_block, &[checked.into()]);
13829− builder.switch_to_block(done_block);
13830− builder.seal_block(done_block);
13831− builder.block_params(done_block)[0]
13832− }
13833− Some((RefinePathStep::Relation, rest)) => {
13834− // Loop over every element; each continues along `rest`. The
13835− // loop has a back-edge, so the index AND the running flag ride
13836− // header block parameters (a plain variable can't — its
13837− // reassignment in the body is read at the header, a cycle).
13838− let len = self.call_rt_typed(builder, "knot_relation_len", &[val], types::I64);
13839− let head = builder.create_block();
13840− let idx_p = builder.append_block_param(head, types::I64);
13841− let ok_p = builder.append_block_param(head, types::I32);
13842− let body = builder.create_block();
13843− let after = builder.create_block();
13844− builder.append_block_param(after, types::I32);
13845− let zero = builder.ins().iconst(types::I64, 0);
13846− builder.ins().jump(head, &[zero.into(), ok.into()]);
13847− builder.switch_to_block(head);
13848− // `icmp` yields an i8 boolean; zero-extend to i32 to AND with
13849− // the running flag (so the loop short-circuits once a
13850− // predicate fails).
13851− let in_bounds_i8 = builder.ins().icmp(IntCC::UnsignedLessThan, idx_p, len);
13852− let in_bounds = builder.ins().uextend(types::I32, in_bounds_i8);
13853− let keep_going = builder.ins().band(in_bounds, ok_p);
13854− builder
13855− .ins()
13856− .brif(keep_going, body, &[], after, &[ok_p.into()]);
13857− builder.switch_to_block(body);
13858− builder.seal_block(body);
13859− let elem = self.call_rt(builder, "knot_relation_get", &[val, idx_p]);
13860− let ok_next =
13861− self.emit_refine_walk(builder, elem, rest, refined_name, ok_p, env, db);
13862− let i_next = builder.ins().iadd_imm(idx_p, 1);
13863− builder.ins().jump(head, &[i_next.into(), ok_next.into()]);
13864− builder.seal_block(head);
13865− builder.switch_to_block(after);
13866− builder.seal_block(after);
13867− builder.block_params(after)[0]
13868− }
13869− }
13870− }
13871−
13872− /// Gate a `base.compile` result on every host-refined occurrence in the
13873− /// expected type, reached via `paths`. When the result is `Ok` its `value`
13874− /// is walked along each path, running the host's predicate chain at every
13875− /// refined leaf (descending record fields, unwrapping `Just`, iterating
13876− /// relation elements); any violation swaps `Ok` → a refinement `Err`.
13877− /// `Err` passes through untouched.
13878− ///
13879− /// A bare top-level refined type yields a single `[leaf]` path; the nested
13880− /// cases (records, relations, `Maybe`) carry non-trivial steps.
13881− fn emit_compile_result_refinement_check_paths(
13882− &mut self,
13883− builder: &mut FunctionBuilder,
13884− result: Value,
13885− paths: &[RefinePath],
13886− env: &mut Env,
13887− db: Value,
13888− ) -> Value {
13889− if paths.is_empty() {
13890− return result;
13891− }
1389213829
13893− // Split on the Result tag: `Err` bypasses the check; `Ok` unwraps its
13894− // `value` and walks every path.
13895− let merge_block = builder.create_block();
13896− builder.append_block_param(merge_block, self.ptr_type);
13897− let ok_block = builder.create_block();
13898− let err_block = builder.create_block();
13899− let (ok_tag_ptr, ok_tag_len) = self.string_ptr(builder, "Ok");
13900− let is_ok = self.call_rt_typed(
13901− builder,
13902− "knot_constructor_matches",
13903− &[result, ok_tag_ptr, ok_tag_len],
13904− types::I32,
13905− );
13906− builder.ins().brif(is_ok, ok_block, &[], err_block, &[]);
13907−
13908− // Err path: pass the result straight through.
13909− builder.switch_to_block(err_block);
13910− builder.seal_block(err_block);
13911− builder.ins().jump(merge_block, &[result.into()]);
13830+ // Unwrap a Maybe-wrapped collection (`Just {value: coll}`); a `Nothing`
13831+ // override carries no elements to check. A non-Maybe override is the
13832+ // collection itself — `knot_override_unwrap_just` returns null for a
13833+ // non-Just value, so only unwrap when the descriptor was Maybe-wrapped.
13834+ let is_maybe = self
13835+ .overridable_constants
13836+ .get(flag_name)
13837+ .map(|t| t.starts_with("Maybe "))
13838+ .unwrap_or(false);
13839+ let coll = if is_maybe {
13840+ self.call_rt(builder, "knot_override_unwrap_just", &[override_val])
13841+ } else {
13842+ override_val
13843+ };
1391213844
13913− // Ok path: extract `.value` and walk each refinement path.
13914− builder.switch_to_block(ok_block);
13915− builder.seal_block(ok_block);
13916− let payload = self.call_rt(builder, "knot_constructor_payload", &[result]);
13917− let (v_key_ptr, v_key_len) = self.string_ptr(builder, "value");
13918− let top = self.call_rt(builder, "knot_record_field", &[payload, v_key_ptr, v_key_len]);
13919−
13920− // Global all-predicates-passed flag, threaded as a value: each path
13921− // folds its verdicts in and returns the accumulated flag.
13922− let mut all_ok = builder.ins().iconst(types::I32, 1);
13923− for path in paths {
13924− all_ok = self.emit_refine_walk(
13845+ let (flag_ptr, flag_len) = self.string_ptr(builder, flag_name);
13846+ let (ty_ptr, ty_len) = self.string_ptr(builder, type_name);
13847+ for pred in &preds {
13848+ let pred_fn = self.compile_expr(builder, pred, env, db);
13849+ self.call_rt_void(
1392513850 builder,
13926− top,
13927− &path.steps,
13928− &path.refined_name,
13929− all_ok,
13930− env,
13931− db,
13851+ "knot_override_check_refined_each",
13852+ &[db, coll, pred_fn, flag_ptr, flag_len, ty_ptr, ty_len],
1393213853 );
1393313854 }
13934− let pass_block = builder.create_block();
13935− let fail_block = builder.create_block();
13936− builder.ins().brif(all_ok, pass_block, &[], fail_block, &[]);
13937−
13938− // All predicates held: keep the original Ok.
13939− builder.switch_to_block(pass_block);
13940− builder.seal_block(pass_block);
13941− builder.ins().jump(merge_block, &[result.into()]);
13942−
13943− // A predicate failed: swap to a refinement Err naming that refined type.
13944− builder.switch_to_block(fail_block);
13945− builder.seal_block(fail_block);
13946− let err_val = self.build_refinement_err(builder, &paths[0].refined_name);
13947− builder.ins().jump(merge_block, &[err_val.into()]);
13948−
13949− builder.switch_to_block(merge_block);
13950− builder.seal_block(merge_block);
13951− builder.block_params(merge_block)[0]
1395213855 }
1395313856
1395413857 fn emit_override_literal(
@@ -19056,66 +18959,17 @@ pub(crate) fn split_schema_fields(s: &str) -> Vec<(String, String)> {
1905618959}
1905718960
1905818961/// Parse a `{name Kind name2 Kind2}` record descriptor into (name, Kind) pairs.
18962+/// Parse a record descriptor's `{name Kind name2 Kind2}` into (name, kind)
18963+/// pairs, using the single-owner `knot-descriptor` grammar. Field kinds are
18964+/// re-emitted in descriptor form (verbatim).
1905918965fn parse_schema_columns(schema: &str) -> Vec<(String, String)> {
19060− let t = schema.trim();
19061− let inner = match t.strip_prefix('{').and_then(|s| s.strip_suffix('}')) {
19062− Some(b) => b,
19063− None => return Vec::new(),
19064− };
19065− let mut out = Vec::new();
19066− let bytes = inner.as_bytes();
19067− let mut i = 0;
19068− while i < bytes.len() {
19069− // skip whitespace
19070− while i < bytes.len() && (bytes[i] as char).is_whitespace() {
19071− i += 1;
19072− }
19073− if i >= bytes.len() {
19074− break;
19075− }
19076− // field name
19077− let name_start = i;
19078− while i < bytes.len() && ((bytes[i] as char).is_alphanumeric() || bytes[i] == b'_') {
19079− i += 1;
19080− }
19081− let name = &inner[name_start..i];
19082− // skip ws
19083− while i < bytes.len() && (bytes[i] as char).is_whitespace() {
19084− i += 1;
19085− }
19086− // kind: balanced bracket group or a bare scalar ident
19087− let kind_start = i;
19088− if i < bytes.len() && (bytes[i] == b'{' || bytes[i] == b'(' || bytes[i] == b'[') {
19089− let open = bytes[i];
19090− let close = match open {
19091− b'{' => b'}',
19092− b'(' => b')',
19093− _ => b']',
19094− };
19095− let mut depth = 0usize;
19096− while i < bytes.len() {
19097− if bytes[i] == open {
19098− depth += 1;
19099− } else if bytes[i] == close {
19100− depth -= 1;
19101− if depth == 0 {
19102− i += 1;
19103− break;
19104− }
19105− }
19106− i += 1;
19107− }
19108− } else {
19109− while i < bytes.len() && ((bytes[i] as char).is_alphanumeric() || bytes[i] == b'_') {
19110− i += 1;
19111− }
19112− }
19113− let kind = &inner[kind_start..i];
19114− if !name.is_empty() {
19115− out.push((name.to_string(), kind.to_string()));
19116− }
18966+ match knot_descriptor::parse(schema.trim()) {
18967+ Ok(knot_descriptor::Desc::Record(fields)) => fields
18968+ .into_iter()
18969+ .map(|(name, ty)| (name, knot_descriptor::emit(&ty)))
18970+ .collect(),
18971+ _ => Vec::new(),
1911718972 }
19118− out
1911918973}
1912018974
1912118975// ── Pipe chain analysis ───────────────────────────────────────────
@@ -22135,6 +21989,10 @@ fn ast_type_to_descriptor_type(
2213521989 ast::TypeKind::App { func, arg } => {
2213621990 if matches!(&func.node, ast::TypeKind::Named(n) if n == "Maybe") {
2213721991 format!("?{}", ast_type_to_descriptor_type(arg, aliases))
21992+ } else if matches!(&func.node, ast::TypeKind::Named(n) if n == "Int" || n == "Float") {
21993+ // A unit-bearing numeric (`Int M`): units are phantom for the
21994+ // wire, so the descriptor carries just the base numeric type.
21995+ ast_type_to_descriptor_type(func, aliases)
2213821996 } else {
2213921997 "text".to_string()
2214021998 }
@@ -22158,7 +22016,6 @@ fn ast_type_to_descriptor_type(
2215822016 ast::TypeKind::Relation(inner) => {
2215922017 format!("[{}]", ast_type_to_descriptor_type(inner, aliases))
2216022018 }
22161− ast::TypeKind::UnitAnnotated { base, .. } => ast_type_to_descriptor_type(base, aliases),
2216222019 // A refined field (`amount: Int 1 where \x -> x > 0`) is transparent for
2216322020 // the wire: it must carry the base type's descriptor, not the `text`
2216422021 // fallback — otherwise valid JSON numbers/objects are (de)serialized as
@@ -22228,11 +22085,13 @@ fn resolve_type_for_descriptor(
2222822085 vec![("value".into(), resolve_type_for_descriptor(arg, aliases))],
2222922086 ),
2223022087 ])
22088+ } else if matches!(&func.node, ast::TypeKind::Named(n) if n == "Int" || n == "Float") {
22089+ // A unit-bearing numeric (`Int M`): erase the unit for the wire.
22090+ resolve_type_for_descriptor(func, aliases)
2223122091 } else {
2223222092 ResolvedType::Text
2223322093 }
2223422094 }
22235− ast::TypeKind::UnitAnnotated { base, .. } => resolve_type_for_descriptor(base, aliases),
2223622095 // Refined types are transparent for the wire descriptor; recurse into
2223722096 // the base so the response side agrees with the request side.
2223822097 ast::TypeKind::Refined { base, .. } => resolve_type_for_descriptor(base, aliases),
@@ -22315,10 +22174,39 @@ impl CompileLit {
2231522174
2231622175/// Extract a literal from an AST expression (peeling annotations).
2231722176pub(crate) fn extract_literal(expr: &ast::Expr) -> Option<CompileLit> {
22177+ use knot::ast::{BinOp, UnaryOp};
2231822178 match &expr.node {
2231922179 ast::ExprKind::Lit(ast::Literal::Int(s)) => s.parse::<i64>().ok().map(CompileLit::Int),
2232022180 ast::ExprKind::Lit(ast::Literal::Float(f)) => Some(CompileLit::Float(*f)),
2232122181 ast::ExprKind::Lit(ast::Literal::Text(s)) => Some(CompileLit::Text(s.clone())),
22182+ // Fold constant arithmetic so a compile-time-known value built from
22183+ // literals — e.g. `age (0 - 5)` written into a refined field — is
22184+ // recognized and checked against the predicate at compile time instead
22185+ // of deferring to the runtime write-check. Only pure numeric/string
22186+ // ops fold; anything effectful or non-constant yields None (the caller
22187+ // falls back to the runtime check).
22188+ ast::ExprKind::BinOp { op, lhs, rhs } => {
22189+ let l = extract_literal(lhs)?;
22190+ let r = extract_literal(rhs)?;
22191+ match (op, l, r) {
22192+ (BinOp::Add, CompileLit::Int(a), CompileLit::Int(b)) => Some(CompileLit::Int(a.checked_add(b)?)),
22193+ (BinOp::Sub, CompileLit::Int(a), CompileLit::Int(b)) => Some(CompileLit::Int(a.checked_sub(b)?)),
22194+ (BinOp::Mul, CompileLit::Int(a), CompileLit::Int(b)) => Some(CompileLit::Int(a.checked_mul(b)?)),
22195+ (BinOp::Div, CompileLit::Int(a), CompileLit::Int(b)) if b != 0 => Some(CompileLit::Int(a.checked_div(b)?)),
22196+ (BinOp::Mod, CompileLit::Int(a), CompileLit::Int(b)) if b != 0 => Some(CompileLit::Int(a.checked_rem(b)?)),
22197+ (BinOp::Add, CompileLit::Float(a), CompileLit::Float(b)) => Some(CompileLit::Float(a + b)),
22198+ (BinOp::Sub, CompileLit::Float(a), CompileLit::Float(b)) => Some(CompileLit::Float(a - b)),
22199+ (BinOp::Mul, CompileLit::Float(a), CompileLit::Float(b)) => Some(CompileLit::Float(a * b)),
22200+ (BinOp::Div, CompileLit::Float(a), CompileLit::Float(b)) => Some(CompileLit::Float(a / b)),
22201+ (BinOp::Concat, CompileLit::Text(a), CompileLit::Text(b)) => Some(CompileLit::Text(format!("{a}{b}"))),
22202+ _ => None,
22203+ }
22204+ }
22205+ ast::ExprKind::UnaryOp { op: UnaryOp::Neg, operand } => match extract_literal(operand)? {
22206+ CompileLit::Int(a) => Some(CompileLit::Int(a.checked_neg()?)),
22207+ CompileLit::Float(a) => Some(CompileLit::Float(-a)),
22208+ CompileLit::Text(_) => None,
22209+ },
2232222210 ast::ExprKind::Annot { expr, .. } => extract_literal(expr),
2232322211 _ => None,
2232422212 }
@@ -22386,6 +22274,22 @@ pub(crate) fn eval_refine_predicate_pub(pred: &ast::Expr, lit: &CompileLit) -> O
2238622274 eval_refine_predicate(pred, lit)
2238722275}
2238822276
22277+/// Const-check a refinement predicate against a compile-time-constant
22278+/// *expression* of any shape (not just a `CompileLit` scalar) by rendering it
22279+/// to source and running `pred expr` through the in-process JIT. This reaches
22280+/// whole-record values — e.g. `[{age (0-5)}]` written to `Rel VP` where `VP`
22281+/// is a record-level refined alias — which have no `CompileLit` representation.
22282+/// Returns `Some(bool)` when the predicate can be evaluated purely, `None`
22283+/// otherwise (caller falls back to the runtime write-check).
22284+pub(crate) fn eval_refine_predicate_on_const(
22285+ pred: &ast::Expr,
22286+ value: &ast::Expr,
22287+) -> Option<bool> {
22288+ let vsrc = knot::format::render_expr_source(value);
22289+ let src = format!("({}) ({vsrc})", knot::format::render_expr_source(pred));
22290+ eval_pure_to_bool(&src)
22291+}
22292+
2238922293/// Evaluate an expression to a boolean, with the refinement variable
2239022294/// `param_name` bound to `lit`. Returns `None` if the expression can't be
2239122295/// evaluated (e.g. references a variable other than `param_name`).
crates/knot-compiler/src/infer.rs+379−64
@@ -91,26 +91,32 @@ fn is_unit_arg(expr: &ast::Expr) -> bool {
9191}
9292
9393/// Map a value-spine unit argument to its `UnitExpr` AST.
94−fn unit_arg_to_ast(expr: &ast::Expr) -> ast::UnitExpr {
95− match &expr.node {
96− ast::ExprKind::Lit(ast::Literal::Int(n)) if n == "1" => ast::UnitExpr::Dimensionless,
97− ast::ExprKind::Constructor(name) => ast::UnitExpr::Named(name.clone()),
94+fn unit_arg_to_ast(expr: &ast::Expr) -> ast::Type {
95+ let span = expr.span;
96+ let node = match &expr.node {
97+ ast::ExprKind::Lit(ast::Literal::Int(n)) if n == "1" => {
98+ ast::TypeKind::Unit(ast::UnitExpr::Dimensionless)
99+ }
100+ ast::ExprKind::Constructor(name) => {
101+ ast::TypeKind::Unit(ast::UnitExpr::Named(name.clone()))
102+ }
98103 ast::ExprKind::Var(ast::Binding::User(name)) => {
99− ast::UnitExpr::Named(name.clone())
104+ ast::TypeKind::Unit(ast::UnitExpr::Named(name.clone()))
100105 }
101− ast::ExprKind::TypeHole => ast::UnitExpr::Hole,
106+ ast::ExprKind::TypeHole => ast::TypeKind::Unit(ast::UnitExpr::Hole),
102107 // Compound unit in value-spine form: `(M / S)` arrives as a `BinOp`.
103108 ast::ExprKind::BinOp { op, lhs, rhs } => {
104109 let l = unit_arg_to_ast(lhs);
105110 let r = unit_arg_to_ast(rhs);
106111 match op {
107− ast::BinOp::Mul => ast::UnitExpr::Mul(Box::new(l), Box::new(r)),
108− ast::BinOp::Div => ast::UnitExpr::Div(Box::new(l), Box::new(r)),
109− _ => ast::UnitExpr::Dimensionless,
112+ ast::BinOp::Mul => ast::TypeKind::UnitMul(Box::new(l), Box::new(r)),
113+ ast::BinOp::Div => ast::TypeKind::UnitDiv(Box::new(l), Box::new(r)),
114+ _ => ast::TypeKind::Unit(ast::UnitExpr::Dimensionless),
110115 }
111116 }
112− _ => ast::UnitExpr::Dimensionless,
113− }
117+ _ => ast::TypeKind::Unit(ast::UnitExpr::Dimensionless),
118+ };
119+ ast::Spanned::new(node, span)
114120}
115121
116122// ── Monad info (shared with codegen) ──────────────────────────────
@@ -1617,6 +1623,133 @@ impl Infer {
16171623 }
16181624 }
16191625
1626+ /// Compile-time check: a compile-time-constant `body` flowing where the
1627+ /// *nominal refined* type `expected` is required (e.g. a function whose
1628+ /// declared return type is `Nat` returning a literal) is run through the
1629+ /// predicate now. A passing constant is right-by-construction; a failing
1630+ /// one is a compile error. Anything not statically evaluable is left to the
1631+ /// normal path (which requires `refine`). Reports the error but does not
1632+ /// alter the subsequent unification — the caller still runs its usual
1633+ /// check, so type errors surface normally on top of this refinement error.
1634+ fn check_const_refined_intro(&mut self, body: &ast::Expr, expected: &Ty) {
1635+ let applied = self.apply(expected);
1636+ let refined_name = match applied.peel_alias() {
1637+ Ty::Con(name, args) if args.is_empty() && self.refined_types.contains_key(name) => {
1638+ name.clone()
1639+ }
1640+ _ => return,
1641+ };
1642+ let Some(lit) = crate::codegen::extract_literal_with_consts(body, &self.const_literals)
1643+ else {
1644+ return;
1645+ };
1646+ let pred = self.refined_types.get(&refined_name).unwrap().1.clone();
1647+ if let Some(false) = crate::codegen::eval_refine_predicate_pub(&pred, &lit) {
1648+ self.error(
1649+ format!(
1650+ "constant {} does not satisfy the predicate for refined type `{}`",
1651+ lit.display(),
1652+ refined_name
1653+ ),
1654+ body.span,
1655+ );
1656+ }
1657+ }
1658+
1659+ /// For a relation element type that names (or structurally is) a record
1660+ /// alias carrying refined fields, synthesize its whole-record predicate.
1661+ /// Returns `(type_name, predicate)` where `predicate` is
1662+ /// `\row -> pred(row.f1) && pred(row.f2) && …`.
1663+ fn record_refined_pred_for(&self, elem: Ty) -> Option<(String, ast::Expr)> {
1664+ let applied = self.apply(&elem);
1665+ // The alias name survives as `Ty::Alias(name, _)`; look up its
1666+ // per-field refined predicates directly.
1667+ if let Ty::Alias(name, _) = &applied
1668+ && let Some(fps) = self.refined_field_preds.get(name)
1669+ && !fps.is_empty()
1670+ {
1671+ let pred = self.synthesize_record_pred(fps)?;
1672+ return Some((name.clone(), pred));
1673+ }
1674+ None
1675+ }
1676+
1677+ /// Build the whole-record predicate for a record alias from its per-field
1678+ /// predicates. Each `refined_field_preds` entry is already a row-level
1679+ /// lambda `\row -> <field-pred> row.fi`, so the whole-record predicate is
1680+ /// `\row -> p1(row) && … && pn(row)`.
1681+ fn synthesize_record_pred(&self, fps: &[(String, ast::Expr)]) -> Option<ast::Expr> {
1682+ let dummy = knot::ast::Span { start: 0, end: 0 };
1683+ let mut conj: Option<ast::Expr> = None;
1684+ for (_field, field_pred) in fps {
1685+ // `(field_pred) __row` — apply the row-level field predicate to the
1686+ // row being checked.
1687+ let applied = ast::Expr {
1688+ node: ast::ExprKind::App {
1689+ func: Box::new(field_pred.clone()),
1690+ arg: Box::new(ast::Expr {
1691+ node: ast::ExprKind::Var(ast::Binding::User("__row".to_string())),
1692+ span: dummy,
1693+ }),
1694+ },
1695+ span: dummy,
1696+ };
1697+ conj = Some(match conj {
1698+ None => applied,
1699+ Some(acc) => ast::Expr {
1700+ node: ast::ExprKind::BinOp {
1701+ op: ast::BinOp::And,
1702+ lhs: Box::new(acc),
1703+ rhs: Box::new(applied),
1704+ },
1705+ span: dummy,
1706+ },
1707+ });
1708+ }
1709+ let conj = conj?;
1710+ Some(ast::Expr {
1711+ node: ast::ExprKind::Lambda {
1712+ params: vec![ast::Pat {
1713+ node: ast::PatKind::Var("__row".into()),
1714+ span: dummy,
1715+ }],
1716+ ty_params: Vec::new(),
1717+ body: Box::new(conj),
1718+ },
1719+ span: dummy,
1720+ })
1721+ }
1722+
1723+ /// Compile-time check for a whole-record refined element type written to a
1724+ /// source: evaluate the record-level predicate against each constant row
1725+ /// literal in the written value. A passing row is right-by-construction; a
1726+ /// failing one is a compile error rather than a runtime write-abort. Rows
1727+ /// that aren't statically evaluable are skipped (the runtime write-check
1728+ /// still validates them).
1729+ fn check_const_rows_against_pred(
1730+ &mut self,
1731+ value: &ast::Expr,
1732+ type_name: &str,
1733+ pred: &ast::Expr,
1734+ ) {
1735+ let rows: Vec<&ast::Expr> = match &value.node {
1736+ ast::ExprKind::List(elems) => elems.iter().collect(),
1737+ // A single record literal written directly (no list wrapper).
1738+ ast::ExprKind::Record(fields) if !fields.is_empty() => vec![value],
1739+ _ => return,
1740+ };
1741+ for row in rows {
1742+ if let Some(false) = crate::codegen::eval_refine_predicate_on_const(pred, row) {
1743+ self.error(
1744+ format!(
1745+ "constant row does not satisfy the predicate for refined type `{type_name}`",
1746+ ),
1747+ row.span,
1748+ );
1749+ }
1750+ }
1751+ }
1752+
16201753 fn resolve_refined_base(&mut self, name: &str, span: Span) -> Option<Ty> {
16211754 let mut visited: Vec<String> = vec![name.to_string()];
16221755 let mut current = self.refined_types.get(name)?.0.clone();
@@ -1967,6 +2100,29 @@ impl Infer {
19672100 }
19682101 }
19692102
2103+ /// Interpret a unit-shaped type argument of `Int`/`Float` as a unit.
2104+ /// Returns `Some` only when the argument is built entirely from unit atoms
2105+ /// (`Unit`) and unit algebra (`UnitMul`/`UnitDiv`); any other type (`Record`,
2106+ /// `Function`, a real type `App`, …) yields `None` so the caller falls back
2107+ /// to ordinary type application. This is the single place that knows the
2108+ /// argument of `Int`/`Float` is a unit dimension.
2109+ fn ast_type_to_unit_ty(&mut self, ty: &ast::Type) -> Option<UnitTy> {
2110+ match &ty.node {
2111+ ast::TypeKind::Unit(u) => Some(self.ast_unit_to_unit_ty(u)),
2112+ ast::TypeKind::UnitMul(a, b) => {
2113+ let a = self.ast_type_to_unit_ty(a)?;
2114+ let b = self.ast_type_to_unit_ty(b)?;
2115+ Some(a.mul(&b))
2116+ }
2117+ ast::TypeKind::UnitDiv(a, b) => {
2118+ let a = self.ast_type_to_unit_ty(a)?;
2119+ let b = self.ast_type_to_unit_ty(b)?;
2120+ Some(a.div(&b))
2121+ }
2122+ _ => None,
2123+ }
2124+ }
2125+
19702126 fn error(&mut self, msg: String, span: Span) {
19712127 // Dedup identical diagnostics: the alias fixpoint and multi-pass
19722128 // collection can re-derive the same error at the same span several
@@ -4242,7 +4398,7 @@ impl Infer {
42424398 if shadows_enclosing {
42434399 self.error(
42444400 format!(
4245− "`{name}` is already defined in an enclosing scope, and shadowing is not allowed"
4401+ "`{name}` is already defined in an enclosing scope , and shadowing is not allowed"
42464402 ),
42474403 span,
42484404 );
@@ -4481,22 +4637,25 @@ impl Infer {
44814637 // A numeric base as a type argument. Optionally followed by a
44824638 // unit argument (`1`, a unit atom like `Ms`, or a unit var) —
44834639 // `Float 1`, `Int Ms`. Bare (no unit) means dimensionless.
4484− let unit = match spine.get(consumed) {
4640+ // Built as ordinary application `App(Int, unit)`; the `App`
4641+ // normalization in `ast_type_to_ty` produces the unit-bearing
4642+ // `Con("Int", [Unit(..)])`.
4643+ match spine.get(consumed) {
44854644 Some(u_expr) if is_unit_arg(u_expr) => {
44864645 consumed += 1;
44874646 // The unit argument is erased with the type: record its
44884647 // span so codegen drops it from the value spine.
44894648 self.type_arg_spans.insert(u_expr.span);
4490− unit_arg_to_ast(u_expr)
4649+ let base = knot::ast::Spanned {
4650+ node: TypeKind::Named(name.clone()),
4651+ span: head.span,
4652+ };
4653+ TypeKind::App {
4654+ func: Box::new(base),
4655+ arg: Box::new(unit_arg_to_ast(u_expr)),
4656+ }
44914657 }
4492− _ => knot::ast::UnitExpr::Dimensionless,
4493− };
4494− TypeKind::UnitAnnotated {
4495− base: Box::new(knot::ast::Spanned {
4496− node: TypeKind::Named(name.clone()),
4497− span: head.span,
4498− }),
4499− unit,
4658+ _ => TypeKind::Named(name.clone()),
45004659 }
45014660 } else {
45024661 TypeKind::Named(name.clone())
@@ -4691,6 +4850,34 @@ impl Infer {
46914850 if let Some(t) = self.expand_param_alias(ty) {
46924851 return t;
46934852 }
4853+ // Unit normalization (the ONLY unit-aware front-end step): an
4854+ // `Int`/`Float` applied to a unit-shaped argument — `Int M`,
4855+ // `Float u`, `Int 1`, `Int (M / S)` — becomes the canonical
4856+ // unit-bearing type `Con("Int"/"Float", [Unit(u)])`. The
4857+ // argument is interpreted as unit algebra; see `ast_type_to_unit_ty`.
4858+ if let ast::TypeKind::Named(n) = &func.node
4859+ && (n == "Int" || n == "Float")
4860+ && let Some(unit_ty) = self.ast_type_to_unit_ty(arg)
4861+ {
4862+ let saved_flag = self.in_type_annotation;
4863+ let saved_enforce = self.enforce_units;
4864+ self.in_type_annotation = false;
4865+ self.enforce_units = false;
4866+ let base_ty = self.ast_type_to_ty(func);
4867+ self.in_type_annotation = saved_flag;
4868+ self.enforce_units = saved_enforce;
4869+ return match base_ty {
4870+ Ty::Int => Ty::int_with_unit(unit_ty),
4871+ Ty::Float => Ty::float_with_unit(unit_ty),
4872+ _ => {
4873+ self.error(
4874+ "unit annotations are only allowed on Int and Float types".into(),
4875+ ty.span,
4876+ );
4877+ Ty::Error
4878+ }
4879+ };
4880+ }
46944881 let arg_ty = self.ast_type_to_ty(arg);
46954882 let func_ty = self.ast_type_to_ty(func);
46964883 match func_ty {
@@ -4730,35 +4917,20 @@ impl Infer {
47304917 Ty::Variant(ctor_tys, row_var)
47314918 }
47324919 ast::TypeKind::IO { ty: inner_ty } => Ty::IO(Box::new(self.ast_type_to_ty(inner_ty))),
4733− ast::TypeKind::UnitAnnotated { base, unit } => {
4734− // Convert the base (`Int`/`Float`) without the bare-numeric
4735− // check — the unit is supplied right here.
4736− let saved_flag = self.in_type_annotation;
4737− let saved_enforce = self.enforce_units;
4738− self.in_type_annotation = false;
4739− self.enforce_units = false;
4740− let base_ty = self.ast_type_to_ty(base);
4741− self.in_type_annotation = saved_flag;
4742− self.enforce_units = saved_enforce;
4743− let unit_ty = self.ast_unit_to_unit_ty(unit);
4744− match base_ty {
4745− Ty::Int => Ty::int_with_unit(unit_ty),
4746− Ty::Float => Ty::float_with_unit(unit_ty),
4747− _ => {
4748− self.error(
4749− "unit annotations are only allowed on Int and Float types".into(),
4750− ty.span,
4751− );
4752− Ty::Error
4753− }
4754− }
4755− }
47564920 ast::TypeKind::Unit(_unit) => {
4757− // A standalone type-level unit expression. Only meaningful as
4758− // the argument of `Con("Int"/"Float", [Unit(u)])`, which is
4759− // built via `UnitAnnotated`. Reaching here means the unit
4760− // appeared bare in a type position — treat it as an error
4761− // since a unit is not a value-inhabited type.
4921+ // A standalone type-level unit atom. Only meaningful as the
4922+ // argument of `Int`/`Float` (handled by the `App` unit
4923+ // normalization). Reaching here means the unit appeared bare in
4924+ // a type position — an error, since a unit is not a
4925+ // value-inhabited type.
4926+ self.error(
4927+ "a unit expression cannot appear as a standalone type — it must be the argument of Int or Float".into(),
4928+ ty.span,
4929+ );
4930+ Ty::Error
4931+ }
4932+ ast::TypeKind::UnitMul(..) | ast::TypeKind::UnitDiv(..) => {
4933+ // Unit algebra outside an `Int`/`Float` argument.
47624934 self.error(
47634935 "a unit expression cannot appear as a standalone type — it must be the argument of Int or Float".into(),
47644936 ty.span,
@@ -6839,10 +7011,34 @@ impl Infer {
68397011 }
68407012 };
68417013 self.push_scope();
7014+ // CLI `#arg` fields resolve globally by name, not lexically, so
7015+ // a nested `with` re-declaring an agreeing `#name` is NOT
7016+ // shadowing (every declaration refers to the same value).
7017+ // `check_with_chain_shadowing` already validated that the
7018+ // declarations agree; exempt them from `bind_at`'s shadow check.
7019+ let cli_arg_names: std::collections::HashSet<String> =
7020+ if let ast::ExprKind::Record(field_exprs) = &record.node {
7021+ field_exprs
7022+ .iter()
7023+ .filter_map(|f| match &f.value.node {
7024+ ast::ExprKind::ArgDecl { name, .. } => Some(name.clone()),
7025+ _ => None,
7026+ })
7027+ .collect()
7028+ } else {
7029+ std::collections::HashSet::new()
7030+ };
68427031 for (name, ty) in &fields {
68437032 // `fields` comes from the resolved record TYPE (no per-field
68447033 // span), so point the shadow error at the whole `with` record.
6845− self.bind_at(name, Scheme::mono(ty.clone()), record.span);
7034+ if cli_arg_names.contains(name) {
7035+ // Bind without the shadowing error (see above).
7036+ if let Some(scope) = self.scopes.last_mut() {
7037+ scope.insert(name.to_string(), Scheme::mono(ty.clone()));
7038+ }
7039+ } else {
7040+ self.bind_at(name, Scheme::mono(ty.clone()), record.span);
7041+ }
68467042 }
68477043 // Mark this scope as a `with` frame (span + field schemes) so
68487044 // the `Var` arm can redirect a `with`-field reference to the
@@ -7314,7 +7510,17 @@ impl Infer {
73147510 break;
73157511 };
73167512 let Some(ty_ast) = first_ty.take() else { break };
7513+ // A type WITNESS argument is written in
7514+ // value position (`asT (Maybe Int) …`), so
7515+ // the unit annotation requirement does not
7516+ // apply — suppress it while interpreting.
7517+ let saved_flag = self.in_type_annotation;
7518+ let saved_enforce = self.enforce_units;
7519+ self.in_type_annotation = false;
7520+ self.enforce_units = false;
73177521 let arg_ty = self.ast_type_to_ty(&ty_ast);
7522+ self.in_type_annotation = saved_flag;
7523+ self.enforce_units = saved_enforce;
73187524 self.type_arg_spans.insert(ty_ast.span);
73197525 let mut mapping: HashMap<TyVar, Ty> = HashMap::new();
73207526 mapping.insert(witness_var, arg_ty);
@@ -7375,7 +7581,15 @@ impl Infer {
73757581 flatten_type_tokens(arg, &mut flat);
73767582 if let Some((ty_ast, consumed)) = self.consume_type_arg(&flat) {
73777583 self.type_arg_spans.insert(ty_ast.span);
7584+ // Type-witness argument in value position: units not
7585+ // enforced (see the other witness site above).
7586+ let saved_flag = self.in_type_annotation;
7587+ let saved_enforce = self.enforce_units;
7588+ self.in_type_annotation = false;
7589+ self.enforce_units = false;
73787590 let ascribed = self.ast_type_to_ty(&ty_ast);
7591+ self.in_type_annotation = saved_flag;
7592+ self.enforce_units = saved_enforce;
73797593 // The witness type is the value's type: pin `rest`
73807594 // (`a -> a`) to `ascribed -> ascribed`. The result of
73817595 // consuming ONLY the type argument is the partially
@@ -7391,6 +7605,13 @@ impl Infer {
73917605 // The first value arg (if present in this same app)
73927606 // is CHECKED against the ascribed type.
73937607 if let Some(first) = value_args.next() {
7608+ // A constant ascribed to a refined type
7609+ // (`base.the (Nat) (0-5)`): evaluate the
7610+ // predicate now so a violating constant is a
7611+ // compile error, not a silent launder (the
7612+ // type-witness path bypasses the introduction
7613+ // guard and never touches a source write).
7614+ self.check_const_refined_intro(first, &ascribed);
73947615 self.check_expr(first, &ascribed);
73957616 result = ascribed.clone();
73967617 }
@@ -7573,6 +7794,18 @@ impl Infer {
75737794 // refined function parameters encountered inside the value.
75747795 let source_refined = self.refined_names_in(&target_inner);
75757796 let prev_suppress = self.suppress_refine_intro.replace(source_refined);
7797+ // Whole-record refined element type (`Rel VP` where `VP` is a
7798+ // record-level refined alias): the per-field check doesn't see
7799+ // the record-level predicate, so const-evaluate it here against
7800+ // each constant row literal. A passing constant is
7801+ // right-by-construction; a failing one is a compile error
7802+ // rather than a runtime write-abort. Non-constant rows defer to
7803+ // the runtime write-check as before.
7804+ if let Ty::Relation(elem) = self.apply(&target_inner).peel_alias() {
7805+ if let Some((ename, pred)) = self.record_refined_pred_for((**elem).clone()) {
7806+ self.check_const_rows_against_pred(value, &ename, &pred);
7807+ }
7808+ }
75767809 self.check_expr(value, &target_inner);
75777810 self.suppress_refine_intro = prev_suppress;
75787811 Ty::IO(Box::new(Ty::unit()))
@@ -7987,6 +8220,12 @@ ast::ExprKind::Atomic(inner) => {
79878220 for (param, p_ty) in params.iter().zip(peeled.iter()) {
79888221 self.check_pattern(param, p_ty);
79898222 }
8223+ // A constant returned where the declared return type is a
8224+ // refined type: evaluate the predicate at compile time so a
8225+ // violating constant is a build error (by-construction),
8226+ // rather than silently laundering it into the refined type
8227+ // with no check anywhere (it never touches a source write).
8228+ self.check_const_refined_intro(body, &current);
79908229 self.check_expr(body, &current);
79918230 self.pop_scope();
79928231 } else {
@@ -8058,7 +8297,45 @@ ast::ExprKind::Atomic(inner) => {
80588297 self.check_expr(&f.value, &sig_ty);
80598298 field_tys.insert(fname.clone(), sig_ty);
80608299 } else if let Some(exp_ty) = expected_fields.get(&fname) {
8061− self.check_expr(&f.value, exp_ty);
8300+ // Refined field with a statically-known value
8301+ // being introduced (the write path suppresses the
8302+ // introduction guard): verify the predicate at
8303+ // compile time instead of deferring to the runtime
8304+ // write-check. A constant that satisfies it is
8305+ // right-by-construction; one that violates it is a
8306+ // compile error, not a runtime abort. Non-constant
8307+ // values (None) fall through to the guarded check,
8308+ // which under suppression defers to the runtime.
8309+ let applied = self.apply(exp_ty);
8310+ if let Ty::Con(rname, rargs) = applied.peel_alias()
8311+ && rargs.is_empty()
8312+ && self.refined_types.contains_key(rname)
8313+ && self.refine_intro_suppressed(rname)
8314+ && let Some(lit) = crate::codegen::extract_literal_with_consts(
8315+ &f.value,
8316+ &self.const_literals,
8317+ )
8318+ {
8319+ let pred = self.refined_types.get(rname).unwrap().1.clone();
8320+ match crate::codegen::eval_refine_predicate_pub(&pred, &lit) {
8321+ Some(true) => {
8322+ let base =
8323+ self.resolve_refined_base(rname, f.value.span).unwrap_or_else(|| applied.clone());
8324+ self.check_expr(&f.value, &base);
8325+ }
8326+ Some(false) => {
8327+ self.error(
8328+ format!(
8329+ "field '{fname}' does not satisfy '{rname}' predicate",
8330+ ),
8331+ f.value.span,
8332+ );
8333+ }
8334+ None => self.check_expr(&f.value, exp_ty),
8335+ }
8336+ } else {
8337+ self.check_expr(&f.value, exp_ty);
8338+ }
80628339 field_tys.insert(fname.clone(), exp_ty.clone());
80638340 } else {
80648341 let val_ty = self.infer_expr(&f.value);
@@ -10311,7 +10588,7 @@ ast::ExprKind::Atomic(inner) => {
1031110588 let span = value.map(|v| v.span).unwrap_or(Span { start: 0, end: 0 });
1031210589 self.error(
1031310590 format!(
10314− "`{name}` is already defined in an enclosing scope, and shadowing is not allowed"
10591+ "`{name}` is already defined in an enclosing scope , and shadowing is not allowed"
1031510592 ),
1031610593 span,
1031710594 );
@@ -13823,8 +14100,10 @@ fn split_host_data_decls(src: &str) -> (CtorSets, String) {
1382314100 let mut rest = src;
1382414101 loop {
1382514102 let trimmed = rest.trim_start();
13826− // Host ADTs are declared `Name Ctor {} Ctor {f T}` — a leading
13827− // uppercase name, a gap, then whitespace-separated constructors.
14103+ // Host ADTs arrive in native knot source form: `Name Ctor {} Ctor {f T}`
14104+ // (the same shape the parser accepts for a with-block variant decl). A
14105+ // leading uppercase name marks a decl; anything else (a bare type like
14106+ // `Int 1` or `Priority`) is the expected type to check.
1382814107 if !trimmed.starts_with(|c: char| c.is_uppercase()) {
1382914108 return (sets, trimmed.to_string());
1383014109 }
@@ -13845,8 +14124,8 @@ fn split_host_data_decls(src: &str) -> (CtorSets, String) {
1384514124 }
1384614125}
1384714126
13848−/// Parse `Name Ctor1 {f T} Ctor2 {}` into `(Name, [(Ctor, [(field,
13849−/// field_src)])])`. Constructors are whitespace-separated (no `=` or `|`).
14127+/// Parse a native variant decl `Name Ctor1 {f T} Ctor2 {}` into
14128+/// `(Name, [(Ctor, [(field, field_src)])])`. Constructors are gap-separated.
1385014129/// Field types are kept as source substrings (e.g. `Int 1`, `Text`) so the JIT
1385114130/// can re-parse them into real `Ty`s and unify against the snippet's payload
1385214131/// types. Returns `None` if the line isn't a well-formed variant decl.
@@ -14118,8 +14397,8 @@ fn check_adt_ctors(
1411814397 }
1411914398 };
1412014399 if !sets_ok {
14121− return false;
14122− }
14400+ return false;
14401+ }
1412314402 // Payload field types, at matching variance. The CONSUMED side destructures
1412414403 // the value, so every field it binds must be PRESENT in the produced side
1412514404 // with a subsumable type; extra fields on the produced side are ignored.
@@ -14323,6 +14602,16 @@ fn check_inner(
1432314602 let mut field_tys: Vec<(String, Ty)> =
1432414603 Vec::with_capacity(field_exprs.len());
1432514604 let mut absent_tys: Vec<Ty> = Vec::new();
14605+ // CLI `#arg` names in this layer: they resolve globally by
14606+ // name, so a nested re-declaration isn't shadowing (the
14607+ // agreement check runs in `check_with_chain_shadowing`).
14608+ let cli_arg_names: std::collections::HashSet<String> = field_exprs
14609+ .iter()
14610+ .filter_map(|f| match &f.value.node {
14611+ ast::ExprKind::ArgDecl { name, .. } => Some(name.clone()),
14612+ _ => None,
14613+ })
14614+ .collect();
1432614615 for f in field_exprs {
1432714616 let val_ty = infer.infer_expr(&f.value);
1432814617 match f.name.as_name() {
@@ -14337,7 +14626,14 @@ fn check_inner(
1433714626 .push((cur.span, field_tys.iter().map(|(n, _)| n.clone()).collect()));
1433814627 infer.push_scope();
1433914628 for (name, ty) in &field_tys {
14340− infer.bind_at(name, Scheme::mono(ty.clone()), record.span);
14629+ if cli_arg_names.contains(name) {
14630+ // Agreeing `#arg`: bind without the shadowing error.
14631+ if let Some(scope) = infer.scopes.last_mut() {
14632+ scope.insert(name.to_string(), Scheme::mono(ty.clone()));
14633+ }
14634+ } else {
14635+ infer.bind_at(name, Scheme::mono(ty.clone()), record.span);
14636+ }
1434114637 }
1434214638 *infer.with_scope_stack.last_mut().expect("just pushed") = Some((
1434314639 cur.span,
@@ -14726,7 +15022,8 @@ fn check_inner(
1472615022 // it — e.g. under polymorphic `show`) carries NO expected type and accepts
1472715023 // whatever the snippet produces.
1472815024 let mut compile_expected_types = CompileExpectedTypes::new();
14729− for (span, res_v) in &infer.compile_calls {
15025+ let compile_calls = std::mem::take(&mut infer.compile_calls);
15026+ for (span, res_v) in &compile_calls {
1473015027 // `res_v` is the inner `a` of the call's `Maybe a` result (the caller's
1473115028 // expected type for the snippet), resolved now that inference is done.
1473215029 let inner = infer.apply(&Ty::Var(*res_v));
@@ -14742,6 +15039,23 @@ fn check_inner(
1474215039 // does — `Int u` (unit unconstrained) means plain `Int`, and the
1474315040 // snippet's dimensionless `Int` must match it.
1474415041 let inner = default_free_unit_vars(&inner);
15042+ // Refined types cannot cross `base.compile`. The expected type crosses
15043+ // to the JIT as a NAME-only wire string — the host's predicates never
15044+ // do — so the JIT could only check a refined type by name, which a
15045+ // snippet redeclaring `type Nat = Int 1 where \_ -> True` would slip
15046+ // past. With no runtime refinement gate to catch the result, reject
15047+ // any host-refined occurrence (top-level or nested) at compile time.
15048+ let refined_here = infer.refined_names_in(&inner);
15049+ if let Some(refined) = refined_here.iter().next() {
15050+ infer.error(
15051+ format!(
15052+ "base.compile cannot use the refined type `{refined}` in its expected type: \
15053+ refinement predicates cannot be enforced across a JIT compile"
15054+ ),
15055+ *span,
15056+ );
15057+ continue;
15058+ }
1474515059 let var_map = var_map_for(&inner);
1474615060 let unit_var_map = unit_var_map_for(&inner);
1474715061 // Render for the WIRE (round-trippable): dimensionless `Int`/`Float`
@@ -15702,10 +16016,11 @@ fn collect_alias_refs(ty: &ast::Type, alias_names: &HashSet<String>, out: &mut H
1570216016 ast::TypeKind::IO { ty, .. } => {
1570316017 collect_alias_refs(ty, alias_names, out);
1570416018 }
15705− ast::TypeKind::UnitAnnotated { base, .. } => {
15706− collect_alias_refs(base, alias_names, out);
15707− }
1570816019 ast::TypeKind::Unit(_) => {}
16020+ ast::TypeKind::UnitMul(a, b) | ast::TypeKind::UnitDiv(a, b) => {
16021+ collect_alias_refs(a, alias_names, out);
16022+ collect_alias_refs(b, alias_names, out);
16023+ }
1570916024 ast::TypeKind::Refined { base, .. } => {
1571016025 collect_alias_refs(base, alias_names, out);
1571116026 }
crates/knot-compiler/src/lockfile.rs+64−1
@@ -62,7 +62,8 @@ fn parse_lockfile(lock_path: &Path) -> Result<SchemaInfo, String> {
6262 ));
6363 }
6464
65− let env = TypeEnv::from_program(&program);
65+ let mut env = TypeEnv::from_program(&program);
66+ env.compute_schemas(&program);
6667
6768 // Current committed schemas come from the parseable `Rel T *name`
6869 // declarations (re-resolved by TypeEnv). The committed migration chain
@@ -250,6 +251,10 @@ fn check_committed_using_types(old: &SchemaInfo, type_env: &TypeEnv) -> Vec<Diag
250251 };
251252 let mut referenced = Vec::new();
252253 collect_qualified_ctor_types(&using, &mut referenced);
254+ // A deleted type referenced only by a field *annotation* (not a
255+ // qualified ctor) would otherwise slip through to codegen's schema
256+ // descriptor and be silently mistyped.
257+ collect_annotation_types(&using, &mut referenced);
253258 for ty_name in referenced {
254259 if !declared.contains(ty_name.as_str()) {
255260 diags.push(Diagnostic::error(format!(
@@ -312,6 +317,64 @@ fn collect_qualified_ctor_types(e: &Expr, out: &mut Vec<String>) {
312317 }
313318}
314319
320+/// Collect every named type referenced by a migration lambda's *type
321+/// annotations* — e.g. `\p -> {name p.name active Active}` annotates `active`
322+/// with `Active`, which a qualified-ctor scan (`Active.Yes`) never sees. A
323+/// deleted type referenced only in an annotation would otherwise slip past the
324+/// lock check and reach codegen's schema descriptor, where it used to be
325+/// silently typed `Text`.
326+fn collect_annotation_types(e: &Expr, out: &mut Vec<String>) {
327+ match &e.node {
328+ ExprKind::Record(fields) => {
329+ for f in fields {
330+ if let Some(sig) = &f.sig {
331+ collect_named_from_type(&sig.ty, out);
332+ }
333+ collect_annotation_types(&f.value, out);
334+ }
335+ }
336+ ExprKind::With { record, body, .. } => {
337+ collect_annotation_types(record, out);
338+ collect_annotation_types(body, out);
339+ }
340+ ExprKind::App { func, arg } => {
341+ collect_annotation_types(func, out);
342+ collect_annotation_types(arg, out);
343+ }
344+ ExprKind::Lambda { body, .. } => collect_annotation_types(body, out),
345+ ExprKind::List(items) => {
346+ for i in items {
347+ collect_annotation_types(i, out);
348+ }
349+ }
350+ _ => {}
351+ }
352+}
353+
354+/// Recursively collect the names of `Named` type nodes within a type
355+/// expression (record field types, application args/funcs, etc.).
356+fn collect_named_from_type(t: &Type, out: &mut Vec<String>) {
357+ match &t.node {
358+ TypeKind::Named(n) => out.push(n.clone()),
359+ TypeKind::App { func, arg } => {
360+ collect_named_from_type(func, out);
361+ collect_named_from_type(arg, out);
362+ }
363+ TypeKind::Record { fields, .. } => {
364+ for f in fields {
365+ collect_named_from_type(&f.value, out);
366+ }
367+ }
368+ TypeKind::Relation(inner) => collect_named_from_type(inner, out),
369+ TypeKind::Function { param, result } => {
370+ collect_named_from_type(param, out);
371+ collect_named_from_type(result, out);
372+ }
373+ TypeKind::IO { ty } => collect_named_from_type(ty, out),
374+ _ => {}
375+ }
376+}
377+
315378/// The committed migration chain per source, read from the lock. Codegen
316379/// bakes this into the binary so a stale database fast-forwards through every
317380/// committed step at startup. Returns an empty map when there is no lockfile
crates/knot-compiler/src/main.rs+42−45
@@ -17,16 +17,16 @@ use std::process;
1717/// Render a single-variant ADT's `data` decl. Single-variant `data Name =
1818/// Ctor {..}` is stored in `aliases` as `ResolvedType::Record` (the ctor name
1919/// is dropped); `ctor_name` recovers it from `TypeEnv.constructors`.
20+///
21+/// Delegates to [`types::adt_to_data_decl`] — the single canonical renderer for
22+/// the `data Name = …` wire form — so the ctor-arm shape (and the
23+/// `resolved_to_source` type-atom convention) lives in exactly one place.
2024fn single_variant_to_data_decl(
2125 name: &str,
2226 ctor_name: &str,
2327 fields: &[(String, types::ResolvedType)],
2428) -> String {
25− let inner: Vec<String> = fields
26− .iter()
27− .map(|(fname, fty)| format!("{fname}: {}", types::resolved_to_source(fty)))
28− .collect();
29− format!("data {name} = {ctor_name} {{{}}}", inner.join(", "))
29+ types::adt_to_data_decl(name, &[(ctor_name.to_string(), fields.to_vec())])
3030}
3131
3232fn prepend_host_data_decls(
@@ -158,18 +158,11 @@ pub unsafe extern "C" fn knot_compile_impl(
158158) -> *mut knot_runtime::Value {
159159 // Null out-params up front so a compile error leaves them well-defined.
160160 unsafe {
161− for (p, l) in [
161+ knot_runtime::compile_out_params_init([
162162 (out_ty_ptr, out_ty_len),
163163 (out_rels_ptr, out_rels_len),
164164 (out_err_ptr, out_err_len),
165− ] {
166− if !p.is_null() {
167− *p = std::ptr::null_mut();
168− }
169− if !l.is_null() {
170− *l = 0;
171− }
172− }
165+ ]);
173166 }
174167
175168 let src_bytes = unsafe { std::slice::from_raw_parts(src_ptr, src_len) };
@@ -185,34 +178,32 @@ pub unsafe extern "C" fn knot_compile_impl(
185178 std::str::from_utf8(b).ok()
186179 };
187180
188− // Hand a string back as a malloc'd buffer (C ABI — the runtime frees it).
189− unsafe fn write_out(s: &str, ptr: *mut *mut u8, len: *mut usize) {
190− if ptr.is_null() || len.is_null() {
191− return;
192− }
193− let buf = unsafe { libc::malloc(s.len().max(1)) as *mut u8 };
194− if buf.is_null() {
195− return;
196− }
197− unsafe {
198− std::ptr::copy_nonoverlapping(s.as_ptr(), buf, s.len());
199− *ptr = buf;
200− *len = s.len();
201− }
202− }
203−
204− match codegen::jit_compile_typed(source, expected) {
205− Some(compiled) => {
206− if let Some(ty) = &compiled.body_ty {
207− unsafe { write_out(ty, out_ty_ptr, out_ty_len) };
208− }
209− let csv = compiled.relations.join(",");
210− unsafe { write_out(&csv, out_rels_ptr, out_rels_len) };
211− let entry_fn: extern "C" fn(*mut std::ffi::c_void) -> *mut knot_runtime::Value =
212− unsafe { std::mem::transmute(compiled.entry) };
213− entry_fn(db)
214− }
215− None => std::ptr::null_mut(),
181+ // `compile` runs inside the host's process, on the host's db handle.
182+ // `run_compile_contained` catches any panic and surfaces it as an Err
183+ // rather than unwinding across this `extern "C"` boundary.
184+ unsafe {
185+ knot_runtime::run_compile_contained(
186+ db,
187+ out_ty_ptr,
188+ out_ty_len,
189+ out_rels_ptr,
190+ out_rels_len,
191+ out_err_ptr,
192+ out_err_len,
193+ |db| match codegen::jit_compile_typed(source, expected) {
194+ Some(compiled) => {
195+ let entry_fn: extern "C" fn(*mut std::ffi::c_void) -> *mut knot_runtime::Value =
196+ std::mem::transmute(compiled.entry);
197+ let value = entry_fn(db);
198+ Some(knot_runtime::CompileOutcome {
199+ value,
200+ ty: compiled.body_ty,
201+ relations: compiled.relations,
202+ })
203+ }
204+ None => None,
205+ },
206+ )
216207 }
217208}
218209
@@ -635,7 +626,7 @@ fn cmd_build(
635626
636627 // Reject `with`-chain shadowing: a field name bound by two layers of the
637628 // top-level `with {…} (with {…} …)` chain silently shadows the outer one.
638− let with_shadow_diags = types::check_with_chain_shadowing(&program);
629+ let (with_shadow_diags, cli_arg_defaults) = types::check_with_chain_shadowing(&program);
639630 if !with_shadow_diags.is_empty() {
640631 for diag in &with_shadow_diags {
641632 eprintln!("{}", diag.render(&source, &filename));
@@ -644,7 +635,8 @@ fn cmd_build(
644635 }
645636
646637 // Resolve types
647− let type_env = types::TypeEnv::from_program(&program);
638+ let mut type_env = types::TypeEnv::from_program(&program);
639+ type_env.cli_arg_defaults = cli_arg_defaults;
648640
649641 // Type inference
650642 let infer::CheckOutput {
@@ -714,6 +706,10 @@ fn cmd_build(
714706 }
715707 }
716708
709+ // Inference passed: safe to derive schemas (an unpersistable or dangling
710+ // field type was rejected above with a clean diagnostic).
711+ type_env.compute_schemas(&program);
712+
717713 // Warn about every `todo` hole left in the codebase: it compiles and runs
718714 // (aborting with a report only if reached), but is a debug placeholder, not
719715 // a real implementation. One warning per hole, with a caret at the site.
@@ -908,7 +904,8 @@ fn cmd_lock(source_file: &str) {
908904 }
909905 process::exit(1);
910906 }
911− let type_env = types::TypeEnv::from_program(&program);
907+ let mut type_env = types::TypeEnv::from_program(&program);
908+ type_env.compute_schemas(&program);
912909
913910 let pending = lockfile::pending_migrations(&program);
914911
crates/knot-compiler/src/types.rs+331−85
@@ -57,10 +57,16 @@ pub fn resolved_to_source(ty: &ResolvedType) -> String {
5757}
5858
5959/// Render a `data` declaration for a named ADT (its full constructor set), e.g.
60−/// `data Priority = Low {} | High {}` or
61−/// `data Status = Open {} | InProgress {assignee: Text}`. Transmitted alongside
62−/// an expected type so the JIT can compare constructor sets against the
63−/// snippet's same-named ADT — nominal-by-structure, not name-only.
60+/// `Priority Low {} High {}` or `Status Open {} InProgress {assignee Text}`.
61+/// Transmitted alongside an expected type so the JIT can compare constructor
62+/// sets against the snippet's same-named ADT — nominal-by-structure, not
63+/// name-only.
64+///
65+/// The wire form IS native knot source syntax — the same shape the parser
66+/// accepts for a with-block variant decl (type name, a gap, then gap-separated
67+/// `Ctor {field Type}` constructors, fields gap-separated with no colon). The
68+/// JIT re-parses it with the same `split_host_data_decls`, so producer and
69+/// consumer can never drift onto different grammars.
6470pub fn adt_to_data_decl(name: &str, ctors: &[(String, Vec<(String, ResolvedType)>)]) -> String {
6571 let arms: Vec<String> = ctors
6672 .iter()
@@ -68,15 +74,27 @@ pub fn adt_to_data_decl(name: &str, ctors: &[(String, Vec<(String, ResolvedType)
6874 if fields.is_empty() {
6975 format!("{cname} {{}}")
7076 } else {
77+ // Native field syntax is `name Type`, gap-separated (no colon).
78+ // A multi-token field type (a function `A -> B`, an application
79+ // `Box Int`, a relation) is parenthesised so the JIT can split
80+ // fields on top-level gaps.
7181 let inner: Vec<String> = fields
7282 .iter()
73− .map(|(fname, fty)| format!("{fname}: {}", resolved_to_source(fty)))
83+ .map(|(fname, fty)| {
84+ let src = resolved_to_source(fty);
85+ match fty {
86+ ResolvedType::Function(..)
87+ | ResolvedType::Relation(_)
88+ | ResolvedType::Adt(_) => format!("{fname} ({src})"),
89+ _ => format!("{fname} {src}"),
90+ }
91+ })
7492 .collect();
75− format!("{cname} {{{}}}", inner.join(", "))
93+ format!("{cname} {{{}}}", inner.join(" "))
7694 }
7795 })
7896 .collect();
79− format!("data {name} = {}", arms.join(" | "))
97+ format!("{name} {}", arms.join(" "))
8098}
8199
82100/// An associated type definition from an impl block.
@@ -113,11 +131,22 @@ pub struct TypeEnv {
113131 pub source_refinements: HashMap<String, Vec<(Option<String>, String, Expr)>>,
114132 /// Refined type aliases: type_name -> predicate expr
115133 pub refined_types: HashMap<String, Expr>,
134+ /// CLI `#`-arg merged defaults: arg name → the concrete literal default any
135+ /// scope supplies (`None` if every declaration is `_`/required). Computed by
136+ /// `check_with_chain_shadowing`; codegen reads it so a `_` declaration with
137+ /// a concrete default elsewhere doesn't emit the missing-argument abort.
138+ pub cli_arg_defaults: HashMap<String, Option<String>>,
116139 /// Multi-variant parameterized data types: name -> (param names,
117140 /// per-constructor raw field types). Used to substitute type arguments
118141 /// at application sites.
119142 #[allow(clippy::type_complexity)]
120143 pub multi_variant_params: HashMap<String, (Vec<String>, Vec<(String, Vec<(String, Type)>)>)>,
144+ /// Parameterized single-variant data types: name -> (param names, raw
145+ /// field types). Populated in `from_program` and consumed by
146+ /// `compute_schemas` so a source whose element applies one (`Box Int`)
147+ /// resolves the actual field types, not the placeholder.
148+ #[allow(clippy::type_complexity)]
149+ pub single_variant_params: HashMap<String, (Vec<String>, Vec<(String, Type)>)>,
121150}
122151
123152impl TypeEnv {
@@ -125,8 +154,6 @@ impl TypeEnv {
125154 pub fn from_program(program: &Expr) -> Self {
126155 let mut aliases = HashMap::new();
127156 let mut constructors = HashMap::new();
128− let mut source_schemas = HashMap::new();
129− let mut migrate_schemas: HashMap<String, String> = HashMap::new();
130157 let associated_types: HashMap<String, Vec<AssocTypeDef>> = HashMap::new();
131158
132159 let mut refined_types: HashMap<String, Expr> = HashMap::new();
@@ -332,62 +359,93 @@ impl TypeEnv {
332359 constructors.insert(name.clone(), fixed);
333360 }
334361
335− // Second pass: compute source schemas, migration schemas, and subset
336− // constraints from the `SourceDecl`/`SubsetConstraint`
337− // markers embedded in the program's record literals.
338− let mut subset_constraints = Vec::new();
339− let mut source_refinements: HashMap<String, Vec<(Option<String>, String, Expr)>> =
340− HashMap::new();
362+ // Subset constraints come from record-literal markers (structural, not
363+ // schema), so they're still collected here.
364+ let mut subset_constraints: Vec<(RelationPath, RelationPath)> = Vec::new();
341365 for f in collect_record_field_markers(program) {
342− match &f.value.node {
343− ExprKind::SubsetConstraint { sub, sup } => {
344− subset_constraints.push((sub.clone(), sup.clone()));
345− }
346− ExprKind::SourceDecl {
347− name,
348− ty,
349− migrations,
350− } => {
351− let schema = schema_for_source(
352− ty,
353− &aliases,
354− &associated_types,
355− &single_variant_params,
356− &multi_variant_params,
357− );
358− source_schemas.insert(name.clone(), schema.clone());
359− let refinements = collect_source_refinements(
360− ty,
361− &refined_types,
362− &alias_ast_types,
363− &data_ctor_decls,
364− );
365− if !refinements.is_empty() {
366− source_refinements.insert(name.clone(), refinements);
367− }
368− for _m in migrations {
369− // The migration's target is the source's own declared
370− // schema (the bare-lambda migration clause carries no `to`).
371− // Only the pending target schema is recorded; the
372− // pre-migration schema is derived from the schema lock
373− // (see `lockfile`). One pending migration per source.
374− migrate_schemas.insert(name.clone(), schema.clone());
375− }
376− }
377− _ => {}
366+ if let ExprKind::SubsetConstraint { sub, sup } = &f.value.node {
367+ subset_constraints.push((sub.clone(), sup.clone()));
378368 }
379369 }
380370
371+ // Schemas are NOT computed here. `from_program` only resolves the
372+ // structural type environment (aliases, constructors, params). Schema
373+ // descriptors (`source_schemas` / `migrate_schemas`) are computed
374+ // separately by `compute_schemas`, which callers run only AFTER type
375+ // inference: a source with an unpersistable (function/IO) or dangling
376+ // field type makes inference fail, and that clean diagnostic must win
377+ // over the schema descriptor's loud non-scalar panic. Computing schemas
378+ // eagerly here would panic before inference reports the real error.
381379 Self {
382380 aliases,
383381 constructors,
384− source_schemas,
385− migrate_schemas,
382+ source_schemas: HashMap::new(),
383+ migrate_schemas: HashMap::new(),
386384 associated_types,
387385 subset_constraints,
388− source_refinements,
386+ source_refinements: HashMap::new(),
389387 refined_types,
388+ cli_arg_defaults: HashMap::new(),
390389 multi_variant_params,
390+ single_variant_params,
391+ }
392+ }
393+
394+ /// Compute the per-source schema descriptors, migration target schemas, and
395+ /// source refinements, filling `source_schemas` / `migrate_schemas` /
396+ /// `source_refinements`. Run only after type inference has passed: a source
397+ /// whose element type is unpersistable (function/IO) or references an
398+ /// undeclared type is rejected by inference / `check_reserved_field_names`
399+ /// with a clean diagnostic, and reaching here means the schema is sound to
400+ /// derive. On a genuinely-unpersistable field the descriptor panics rather
401+ /// than emit a silently-wrong `Text` column.
402+ pub fn compute_schemas(&mut self, program: &Expr) {
403+ let associated_types: HashMap<String, Vec<AssocTypeDef>> = HashMap::new();
404+ // Alias AST types are needed to resolve refinements through aliases.
405+ let mut alias_ast_types: HashMap<String, Type> = HashMap::new();
406+ let mut data_ctor_decls: HashMap<String, Vec<ConstructorDef>> = HashMap::new();
407+ for (name, params, ty, _span) in collect_type_ctors(program) {
408+ if params.is_empty() {
409+ alias_ast_types.insert(name.clone(), ty.clone());
410+ }
411+ }
412+ for (name, _params, ctors, _span) in collect_data_ctors(program) {
413+ data_ctor_decls.insert(name.clone(), ctors.clone());
414+ }
415+ // Use the params resolved in `from_program` — an application of a
416+ // parameterized data type (`Box Int`) must see them.
417+ let single_variant_params = self.single_variant_params.clone();
418+
419+ for f in collect_record_field_markers(program) {
420+ if let ExprKind::SourceDecl {
421+ name,
422+ ty,
423+ migrations,
424+ } = &f.value.node
425+ {
426+ let schema = schema_for_source(
427+ ty,
428+ &self.aliases,
429+ &associated_types,
430+ &single_variant_params,
431+ &self.multi_variant_params,
432+ );
433+ self.source_schemas.insert(name.clone(), schema.clone());
434+ let refinements = collect_source_refinements(
435+ ty,
436+ &self.refined_types,
437+ &alias_ast_types,
438+ &data_ctor_decls,
439+ );
440+ if !refinements.is_empty() {
441+ self.source_refinements.insert(name.clone(), refinements);
442+ }
443+ for _m in migrations {
444+ // The migration's target is the source's own declared
445+ // schema; the pre-migration schema lives in the lock.
446+ self.migrate_schemas.insert(name.clone(), schema.clone());
447+ }
448+ }
391449 }
392450 }
393451}
@@ -658,11 +716,22 @@ pub fn check_alias_cycles(program: &Expr) -> Vec<knot::diagnostic::Diagnostic> {
658716/// one layer. This catches what the per-scope `bind_at` check cannot: the
659717/// declaration record is the INNERMOST `with`'s record, so an outer layer's
660718/// same-named field is a separate binding that no enclosing-scope check sees.
661−pub fn check_with_chain_shadowing(program: &Expr) -> Vec<knot::diagnostic::Diagnostic> {
719+pub fn check_with_chain_shadowing(
720+ program: &Expr,
721+) -> (
722+ Vec<knot::diagnostic::Diagnostic>,
723+ HashMap<String, Option<String>>,
724+) {
662725 use knot::ast::ExprKind;
663726 let mut diags = Vec::new();
664727 // (name, span) of every field bound by an OUTER layer, in chain order.
665728 let mut outer: HashMap<String, Span> = HashMap::new();
729+ // `#`-argument fields seen across the chain: name → (type sig text, the
730+ // default's literal display if it is a scalar literal). CLI args resolve
731+ // globally by name, so a repeated `#name` is NOT shadowing — every
732+ // declaration refers to the same value — as long as they all agree on type
733+ // and default. Tracked separately so an agreeing repeat is allowed.
734+ let mut cli_args: HashMap<String, (Option<String>, Option<String>, Span)> = HashMap::new();
666735 let mut cur = program;
667736 loop {
668737 match &cur.node {
@@ -671,6 +740,12 @@ pub fn check_with_chain_shadowing(program: &Expr) -> Vec<knot::diagnostic::Diagn
671740 // repeated across two outer layers shadows within the chain.
672741 if let ExprKind::Record(fields) = &record.node {
673742 for f in fields {
743+ // A CLI `#arg` is not a shadowing candidate; check it
744+ // against prior `#arg` decls instead.
745+ if let ExprKind::ArgDecl { .. } = &f.value.node {
746+ check_cli_arg_agreement(f, &mut cli_args, &mut diags);
747+ continue;
748+ }
674749 // Absent keys have no name, so they can never shadow.
675750 let Some(fname) = f.name.as_name() else { continue };
676751 if outer.insert(fname.to_string(), f.value.span).is_some() {
@@ -690,6 +765,10 @@ pub fn check_with_chain_shadowing(program: &Expr) -> Vec<knot::diagnostic::Diagn
690765 // Its fields must not repeat any outer layer's name.
691766 if let ExprKind::Record(fields) = &record.node {
692767 for f in fields {
768+ if let ExprKind::ArgDecl { .. } = &f.value.node {
769+ check_cli_arg_agreement(f, &mut cli_args, &mut diags);
770+ continue;
771+ }
693772 let Some(fname) = f.name.as_name() else { continue };
694773 if outer.contains_key(fname) {
695774 diags.push(
@@ -707,7 +786,119 @@ pub fn check_with_chain_shadowing(program: &Expr) -> Vec<knot::diagnostic::Diagn
707786 _ => break,
708787 }
709788 }
710− diags
789+ // Surface the merged CLI-arg defaults: name → the concrete literal default
790+ // if any scope supplies one (`None` if every declaration is `_`/required).
791+ // Codegen uses this so a `_` declaration with a concrete default elsewhere
792+ // doesn't emit the missing-argument abort.
793+ let arg_defaults: HashMap<String, Option<String>> = cli_args
794+ .into_iter()
795+ .map(|(name, (_ty, default, _span))| (name, default))
796+ .collect();
797+ (diags, arg_defaults)
798+}
799+
800+/// Record a CLI `#arg` declaration and check it against prior declarations of
801+/// the same name across the `with` chain. A single declaration is always fine
802+/// (any default). A REPEAT declaration must agree with the earlier one on type
803+/// (the sig) and default — and since agreement must be checkable, a repeated
804+/// arg's default must be a literal or `_`. Two concrete defaults must be equal
805+/// literals; a concrete default may pair with any number of `_` (required)
806+/// declarations, in which case the concrete one supplies the value.
807+fn check_cli_arg_agreement(
808+ f: &knot::ast::RecordField,
809+ cli_args: &mut HashMap<String, (Option<String>, Option<String>, Span)>,
810+ diags: &mut Vec<knot::diagnostic::Diagnostic>,
811+) {
812+ use knot::ast::ExprKind;
813+ let ExprKind::ArgDecl { name, default } = &f.value.node else {
814+ return;
815+ };
816+ // The declared type (the `Type #name` sig), rendered for comparison.
817+ let ty_str = f
818+ .sig
819+ .as_ref()
820+ .map(|s| knot::format::render_type(&s.ty));
821+ // The default, as a literal display (`None` if `_` or non-literal).
822+ let default_str = default
823+ .as_ref()
824+ .and_then(|d| default_literal_display(d));
825+
826+ match cli_args.get(name) {
827+ None => {
828+ cli_args.insert(name.clone(), (ty_str, default_str, f.value.span));
829+ }
830+ Some((prev_ty, prev_default, prev_span)) => {
831+ // This is a REPEAT declaration of the same `#name`. Since every
832+ // declaration must resolve to the same value, agreement is required
833+ // — and it must be CHECKABLE, so a repeated arg needs a literal (or
834+ // `_`) default on every occurrence.
835+ if default.is_some() && default_str.is_none() {
836+ diags.push(
837+ knot::diagnostic::Diagnostic::error(format!(
838+ "argument `#{name}` is declared in multiple scopes, so its default must be a literal (or `_`) here",
839+ ))
840+ .label(f.value.span, "computed defaults can't be compared across scopes"),
841+ );
842+ return;
843+ }
844+ // Types must agree.
845+ if *prev_ty != ty_str {
846+ diags.push(
847+ knot::diagnostic::Diagnostic::error(format!(
848+ "argument `#{name}` is declared with different types",
849+ ))
850+ .label(f.value.span, "this declaration")
851+ .label(*prev_span, "conflicts with this earlier declaration"),
852+ );
853+ return;
854+ }
855+ // Defaults must agree: equal literals, or the `_`-exception (one
856+ // concrete default among `_` declarations).
857+ let defaults_ok = match (prev_default, &default_str) {
858+ // Both `_`: fine.
859+ (None, None) => true,
860+ // One concrete, one `_`: fine (the concrete one supplies the value).
861+ (Some(_), None) | (None, Some(_)) => true,
862+ // Two concrete: must be the same literal.
863+ (Some(a), Some(b)) => a == b,
864+ };
865+ if !defaults_ok {
866+ diags.push(
867+ knot::diagnostic::Diagnostic::error(format!(
868+ "argument `#{name}` is declared with different defaults",
869+ ))
870+ .label(f.value.span, "this declaration")
871+ .label(*prev_span, "conflicts with this earlier declaration"),
872+ );
873+ return;
874+ }
875+ // Keep the concrete default (if any) as the canonical one.
876+ let merged = prev_default.clone().or(default_str);
877+ cli_args.insert(name.clone(), (ty_str, merged, *prev_span));
878+ }
879+ }
880+}
881+
882+/// Render a default-value expression as a literal display string, or `None` if
883+/// it isn't a scalar literal (Int/Float/Text/Bool/Maybe thereof). Mirrors the
884+/// CLI default rendering used at codegen.
885+fn default_literal_display(expr: &knot::ast::Expr) -> Option<String> {
886+ use knot::ast::ExprKind;
887+ match &expr.node {
888+ ExprKind::Lit(lit) => match lit {
889+ knot::ast::Literal::Int(s) => Some(s.clone()),
890+ knot::ast::Literal::Float(f) => Some(format!("{}", f)),
891+ knot::ast::Literal::Text(s) => {
892+ let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
893+ Some(format!("\"{}\"", escaped))
894+ }
895+ },
896+ ExprKind::UnaryOp {
897+ op: knot::ast::UnaryOp::Neg,
898+ operand,
899+ } => default_literal_display(operand).map(|s| format!("-{}", s)),
900+ _ => None,
901+ }
711902}
712903
713904/// Collect the alias names referenced anywhere inside a type AST.
@@ -745,10 +936,14 @@ fn collect_named_alias_refs(ty: &Type, alias_names: &HashSet<String>, out: &mut
745936 TypeKind::IO { ty } => {
746937 collect_named_alias_refs(ty, alias_names, out);
747938 }
748− TypeKind::UnitAnnotated { base, .. } | TypeKind::Refined { base, .. } => {
939+ TypeKind::Refined { base, .. } => {
749940 collect_named_alias_refs(base, alias_names, out);
750941 }
751942 TypeKind::Unit(_) => {}
943+ TypeKind::UnitMul(a, b) | TypeKind::UnitDiv(a, b) => {
944+ collect_named_alias_refs(a, alias_names, out);
945+ collect_named_alias_refs(b, alias_names, out);
946+ }
752947 TypeKind::Forall { ty, .. } => {
753948 collect_named_alias_refs(ty, alias_names, out);
754949 }
@@ -816,6 +1011,14 @@ fn walk_record_sources<'m>(
8161011 ty,
8171012 migrations,
8181013 } => f(name, ty, migrations),
1014+ // Sources live in the `with {…}` declaration block: the program's outer
1015+ // node is a `With` whose record holds the decls. Descend so the
1016+ // SourceDecls inside are actually visited (without this, the walk finds
1017+ // nothing and the checks that rely on it silently no-op).
1018+ ExprKind::With { record, body, .. } => {
1019+ walk_record_sources(record, f);
1020+ walk_record_sources(body, f);
1021+ }
8191022 ExprKind::Record(fields) => {
8201023 for fld in fields {
8211024 walk_record_sources(&fld.value, f);
@@ -851,15 +1054,17 @@ impl<'a> ReservedFieldWalker<'a> {
8511054 self.walk_constructors(constructors, seen);
8521055 }
8531056 TypeKind::Relation(inner) => self.walk(inner, seen),
854− TypeKind::Named(name) => self.walk_named(name, seen),
1057+ TypeKind::Named(name) => self.walk_named(name, ty.span, seen),
8551058 TypeKind::App { func, arg } => {
8561059 self.walk(func, seen);
8571060 self.walk(arg, seen);
8581061 }
859− TypeKind::UnitAnnotated { base, .. } | TypeKind::Refined { base, .. } => {
860− self.walk(base, seen)
861− }
1062+ TypeKind::Refined { base, .. } => self.walk(base, seen),
8621063 TypeKind::Unit(_) => {}
1064+ TypeKind::UnitMul(a, b) | TypeKind::UnitDiv(a, b) => {
1065+ self.walk(a, seen);
1066+ self.walk(b, seen);
1067+ }
8631068 TypeKind::IO { ty } | TypeKind::Forall { ty, .. } => self.walk(ty, seen),
8641069 // A function can never be stored in a column, so nothing below one
8651070 // reaches a table.
@@ -867,7 +1072,7 @@ impl<'a> ReservedFieldWalker<'a> {
8671072 }
8681073 }
8691074
870− fn walk_named(&mut self, name: &'a str, seen: &mut HashSet<&'a str>) {
1075+ fn walk_named(&mut self, name: &'a str, span: Span, seen: &mut HashSet<&'a str>) {
8711076 // A name reached twice is either a cycle (reported by
8721077 // `check_alias_cycles`) or a repeat, whose fields are already checked.
8731078 if !seen.insert(name) {
@@ -879,6 +1084,28 @@ impl<'a> ReservedFieldWalker<'a> {
8791084 if let Some(ctors) = self.data_decls.get(name).copied() {
8801085 self.walk_constructors(ctors, seen);
8811086 }
1087+ // A persisted field type naming something that is neither a builtin
1088+ // scalar, a declared alias, nor a declared data type is dangling: it
1089+ // used to be silently typed `Text` in the schema descriptor. Report it
1090+ // so codegen never produces a wrong schema.
1091+ const BUILTIN_SCALARS: [&str; 6] = ["Int", "Float", "Text", "Bool", "Bytes", "Uuid"];
1092+ let declared = BUILTIN_SCALARS.contains(&name)
1093+ || self.aliases.contains_key(name)
1094+ || self.data_decls.contains_key(name)
1095+ // Single-variant data decls register as aliases; multi-variant as
1096+ // data_decls; parameterized names (`Box`, `Maybe`, …) are applied
1097+ // via App and so arrive here as their resolved arg, not the name.
1098+ || matches!(name, "Maybe" | "Result");
1099+ if !declared && self.reported.insert((name.to_string(), span)) {
1100+ self.diags.push(
1101+ knot::diagnostic::Diagnostic::error(format!(
1102+ "field of relation '*{}' has unknown type '{}'",
1103+ self.relation, name
1104+ ))
1105+ .label(span, format!("'{}' is not declared", name))
1106+ .note("declare it with a `type` alias or a data declaration, or use a builtin scalar (Int, Float, Text, Bool, Bytes, Uuid)"),
1107+ );
1108+ }
8821109 }
8831110
8841111 fn walk_constructors(&mut self, ctors: &'a [ConstructorDef], seen: &mut HashSet<&'a str>) {
@@ -1217,15 +1444,23 @@ fn value_predicates(
12171444 out.push((label, synth_lambda(&param, body)));
12181445 }
12191446 }
1220− TypeKind::UnitAnnotated { base, .. } => {
1447+ TypeKind::UnitMul(a, b) | TypeKind::UnitDiv(a, b) => {
12211448 out.extend(value_predicates(
1222− base,
1449+ a,
1450+ refined_types,
1451+ alias_ast_types,
1452+ data_ctor_decls,
1453+ seen_aliases,
1454+ ));
1455+ out.extend(value_predicates(
1456+ b,
12231457 refined_types,
12241458 alias_ast_types,
12251459 data_ctor_decls,
12261460 seen_aliases,
12271461 ));
12281462 }
1463+ TypeKind::Unit(_) => {}
12291464 // Type application such as `Maybe Nat` or `Result Text Pos`. The
12301465 // refinement lives on a type *argument*, but the runtime value is
12311466 // wrapped in one of the type's constructors (`Just {value: x}`,
@@ -1586,6 +1821,20 @@ fn resolve_type(
15861821 }
15871822 spine_args.reverse();
15881823
1824+ // A unit-bearing numeric (`Int M`): units are phantom for schema
1825+ // resolution, so resolve to the base numeric type.
1826+ if let TypeKind::Named(n) = &spine_head.node
1827+ && (n == "Int" || n == "Float")
1828+ {
1829+ return resolve_type(
1830+ spine_head,
1831+ aliases,
1832+ assoc_types,
1833+ single_variant_params,
1834+ multi_variant_params,
1835+ );
1836+ }
1837+
15891838 // Associated type application: match the whole spine against a
15901839 // definition of matching arity, binding ALL argument patterns into
15911840 // a SINGLE substitution. Matching only the first argument (the old
@@ -1791,17 +2040,8 @@ fn resolve_type(
17912040 multi_variant_params,
17922041 )
17932042 }
1794− TypeKind::UnitAnnotated { base, .. } => {
1795− // Units are phantom — erase for schema resolution
1796− resolve_type(
1797− base,
1798− aliases,
1799− assoc_types,
1800− single_variant_params,
1801− multi_variant_params,
1802− )
1803− }
18042043 TypeKind::Unit(_) => ResolvedType::Named("unknown".into()),
2044+ TypeKind::UnitMul(..) | TypeKind::UnitDiv(..) => ResolvedType::Named("unknown".into()),
18052045 TypeKind::Refined { base, .. } => {
18062046 // Refinements are phantom for schema — base type determines storage
18072047 resolve_type(
@@ -2033,13 +2273,15 @@ fn scalar_type_str(ty: &ResolvedType) -> &'static str {
20332273 ResolvedType::Bool => "Bool",
20342274 ResolvedType::Bytes => "Bytes",
20352275 ResolvedType::Uuid => "Uuid",
2036− // Fallback for non-scalars (`Named`/`Function`/`Unit`). Reached only on
2037− // an already-failing build: the typechecker emits the real diagnostic
2038− // (unpersistable fn/IO field, scalar source, dangling migration
2039− // reference) and codegen still computes a best-effort descriptor that
2040− // is never used. Degrade to Text rather than panic so the clean error
2041− // is what the user sees.
2042− _ => "Text",
2276+ // A non-scalar here means the typechecker's unpersistable-field guard
2277+ // (infer's `first_unpersistable`) missed a case — a compiler bug, not
2278+ // user error. Don't degrade to `Text` (a silently-wrong schema): panic
2279+ // so it surfaces. The `compile` path contains this under `catch_unwind`
2280+ // and returns an Err; the CLI build path aborts with this message.
2281+ other => panic!(
2282+ "schema descriptor: non-scalar field type reached scalar_type_str — \
2283+ the unpersistable-field guard should have rejected it: {other:?}"
2284+ ),
20432285 }
20442286}
20452287
@@ -2199,11 +2441,15 @@ fn apply_type_subst(ty: &Type, subst: &HashMap<String, Type>) -> Type {
21992441 },
22002442 TypeKind::Hole => TypeKind::Hole,
22012443 TypeKind::Callsite => TypeKind::Callsite,
2202− TypeKind::UnitAnnotated { base, unit } => TypeKind::UnitAnnotated {
2203− base: Box::new(apply_type_subst(base, subst)),
2204− unit: unit.clone(),
2205− },
22062444 TypeKind::Unit(u) => TypeKind::Unit(u.clone()),
2445+ TypeKind::UnitMul(a, b) => TypeKind::UnitMul(
2446+ Box::new(apply_type_subst(a, subst)),
2447+ Box::new(apply_type_subst(b, subst)),
2448+ ),
2449+ TypeKind::UnitDiv(a, b) => TypeKind::UnitDiv(
2450+ Box::new(apply_type_subst(a, subst)),
2451+ Box::new(apply_type_subst(b, subst)),
2452+ ),
22072453 TypeKind::Refined { base, predicate } => TypeKind::Refined {
22082454 base: Box::new(apply_type_subst(base, subst)),
22092455 predicate: predicate.clone(),
crates/knot-compiler/src/unused.rs+4−1
@@ -387,8 +387,11 @@ fn walk_type(t: &Type, r: &mut Refs) {
387387 TypeKind::IO { ty } => {
388388 walk_type(ty, r);
389389 }
390− TypeKind::UnitAnnotated { base, .. } => walk_type(base, r),
391390 TypeKind::Unit(_) => {}
391+ TypeKind::UnitMul(a, b) | TypeKind::UnitDiv(a, b) => {
392+ walk_type(a, r);
393+ walk_type(b, r);
394+ }
392395 TypeKind::Refined { base, predicate } => {
393396 walk_type(base, r);
394397 walk_expr(predicate, r);
crates/knot-compiler/tests/compile_panic_containment.rsnew file+51
@@ -0,0 +1,51 @@
1+//! Compile-path panic containment.
2+//!
3+//! `compile` runs in the host's process (via `base.compile`, on the host's db
4+//! handle, possibly mid-transaction). A panic anywhere in compilation must
5+//! never unwind into the host. `knot_compile_impl` (the FFI boundary, in the
6+//! binary) wraps compile+run in `catch_unwind`, writing the payload to `out_err`
7+//! and returning null. These tests exercise the in-process half via the public
8+//! JIT entry point: a source that would reach the schema descriptor's
9+//! non-scalar fallback must fail loudly (return `None`), never silently type a
10+//! column `Text`.
11+
12+use knot_compiler::codegen::jit_compile_typed;
13+
14+/// A relation field typed `Int -> Int` is unpersistable: the typechecker's
15+/// `first_unpersistable` guard rejects it during inference, so the JIT returns
16+/// `None` before codegen ever runs. No panic, no wrong schema — refused cleanly.
17+#[test]
18+fn unpersistable_fn_field_is_rejected() {
19+ let src = "db { *fns { g (Int -> Int) } }\n0";
20+ assert!(
21+ jit_compile_typed(src, None).is_none(),
22+ "a function-typed relation field must fail compilation, not produce a schema"
23+ );
24+}
25+
26+/// A field referencing a type that doesn't resolve (a dangling name) bypasses
27+/// the `first_unpersistable` guard — which only walks `Fun`/`IO` — and reaches
28+/// `field_type_desc`'s non-scalar arm. Previously that arm silently degraded to
29+/// `Text`, producing a wrong schema on an otherwise-"successful" compile. Now
30+/// the fallback panics loudly (a compiler bug: an unpersistable/unresolvable
31+/// field slipped past the guard), and the JIT surface must not hand back a
32+/// bogus compiled value.
33+///
34+/// Whatever the observable outcome — `None` (guard or panic caught) — it must
35+/// NOT be `Some` carrying a schema that mistypes the field as `Text`.
36+#[test]
37+fn unresolvable_named_field_never_yields_a_text_schema() {
38+ let src = "db { *bad { f Missing } }\n0";
39+ let compiled = std::panic::catch_unwind(|| jit_compile_typed(src, None));
40+ match compiled {
41+ // Compile returned (no panic): it must have refused, not succeeded with
42+ // a wrong schema.
43+ Ok(c) => assert!(
44+ c.is_none(),
45+ "a dangling field type must not compile to a schema that says Text"
46+ ),
47+ // The loud fallback panicked — the desired behavior (surfaced as Err at
48+ // the FFI boundary by knot_compile_impl's catch_unwind).
49+ Err(_) => {}
50+ }
51+}
crates/knot-compiler/tests/descriptor_roundtrip.rsnew file+84
@@ -0,0 +1,84 @@
1+//! Cross-validation that the `knot-descriptor` parser accepts every schema
2+//! descriptor the compiler emits, and that its `emit` reproduces the compiler's
3+//! bytes exactly. This is what lets the descriptor dialect have a single owner:
4+//! the emitter (here) and the parser (knot-descriptor) are locked to one
5+//! grammar by this test, so the ~dozens of hand-rolled descriptor scanners in
6+//! the runtime can be replaced by `knot_descriptor::parse` without changing the
7+//! wire format.
8+//!
9+//! Byte-exactness matters because the schema lock compares descriptors with
10+//! string equality (`lockfile.rs`): any drift between emit and re-emit would
11+//! read as a phantom schema change.
12+
13+use knot_compiler::types::TypeEnv;
14+
15+/// Build a program's `TypeEnv` (structural pass) then compute its schemas, and
16+/// return every emitted descriptor string.
17+fn emitted_descriptors(src: &str) -> Vec<String> {
18+ let lexer = knot::lexer::Lexer::new(src);
19+ let (tokens, lex) = lexer.tokenize();
20+ assert!(lex.iter().all(|d| d.severity != knot::diagnostic::Severity::Error));
21+ let parser = knot::parser::Parser::new(src.to_string(), tokens);
22+ let (program, parse) = parser.parse_file_expr();
23+ assert!(
24+ parse.iter().all(|d| d.severity != knot::diagnostic::Severity::Error)
25+ );
26+ let mut env = TypeEnv::from_program(&program);
27+ env.compute_schemas(&program);
28+ env.source_schemas.values().cloned().collect()
29+}
30+
31+/// Every compiler-emitted descriptor parses under knot-descriptor and re-emits
32+/// to the identical bytes.
33+fn assert_round_trips(src: &str) {
34+ let descs = emitted_descriptors(src);
35+ assert!(!descs.is_empty(), "test program must declare a source");
36+ for d in &descs {
37+ let parsed = knot_descriptor::parse(d)
38+ .unwrap_or_else(|e| panic!("knot-descriptor rejected compiler output {d:?}: {e}"));
39+ let reemitted = knot_descriptor::emit(&parsed);
40+ assert_eq!(
41+ &reemitted, d,
42+ "re-emitted descriptor differs from compiler's bytes"
43+ );
44+ }
45+}
46+
47+#[test]
48+fn scalar_source() {
49+ assert_round_trips("with {\nRel (Int 1) *n\n}\n(| yield {})");
50+}
51+
52+#[test]
53+fn flat_record() {
54+ assert_round_trips("with {\nR {name Text age (Int 1)}\nRel R *rs\n}\n(| yield {})");
55+}
56+
57+#[test]
58+fn nested_record_and_relation_field() {
59+ assert_round_trips(
60+ "with {\nAddr {city Text zip (Int 1)}\nR {name Text addr Addr tags (Rel Text)}\nRel R *rs\n}\n(| yield {})",
61+ );
62+}
63+
64+#[test]
65+fn enum_and_payload_adts() {
66+ assert_round_trips(
67+ "with {\nColor Red {} Green {} Blue {}\nS {c Color}\nRel S *s\n}\n(| yield {})",
68+ );
69+ assert_round_trips(
70+ "with {\nShape Circle {radius (Int 1)} Rect {w (Int 1) h (Int 1)} Point {}\nRel Shape *shapes\n}\n(| yield {})",
71+ );
72+}
73+
74+#[test]
75+fn kitchen_sink() {
76+ assert_round_trips(
77+ "with {\n\
78+ Addr {city Text zip (Int 1)}\n\
79+ Shape Circle {radius (Int 1)} Rect {w (Int 1) h (Int 1)} Point {}\n\
80+ Reading {name Text addr Addr shape Shape tags (Rel Text) history (Rel {at (Int 1)})}\n\
81+ Rel Reading *readings\n\
82+ }\n(| yield {})",
83+ );
84+}
crates/knot-descriptor/Cargo.tomlnew file+6
@@ -0,0 +1,6 @@
1+[package]
2+name = "knot-descriptor"
3+version.workspace = true
4+edition.workspace = true
5+
6+[dependencies]
crates/knot-descriptor/src/lib.rsnew file+376
@@ -0,0 +1,376 @@
1+//! The schema-descriptor wire format: a *deliberately terse, minimal* type
2+//! syntax — separate from (and not accepted by) the real knot grammar.
3+//!
4+//! A descriptor is a persisted relation's element type rendered name-free, so
5+//! it survives the type being deleted from source. The compiler emits it; the
6+//! runtime reads it to derive the physical storage layout.
7+//!
8+//! Grammar (whitespace-insensitive except where noted):
9+//!
10+//! ```text
11+//! desc := scalar | record | "(Rel " desc ")" | "(" ctor (" | " ctor)* ")"
12+//! record := "{" field (" "+ field)* "}" // fields separated by 2+ spaces
13+//! field := ident " " desc
14+//! ctor := ident | ident record // payload attaches with NO space
15+//! scalar := "Int" | "Float" | "Text" | "Bool" | "Bytes" | "Uuid"
16+//! ```
17+//!
18+//! This crate is the SINGLE owner of that grammar. Both the compiler's emitter
19+//! and the runtime's readers route through here, so the two sides can never
20+//! drift into different approximations of the format. A round-trip property
21+//! (`emit(parse(s)) == s`) locks emitter and parser to one grammar.
22+
23+use std::fmt;
24+
25+/// A parsed schema descriptor.
26+#[derive(Debug, Clone, PartialEq, Eq)]
27+pub enum Desc {
28+ /// A scalar column type.
29+ Scalar(Scalar),
30+ /// `{name Text addr {city Text}}` — an ordered list of named fields.
31+ Record(Vec<(String, Desc)>),
32+ /// `(Rel Text)` — a relation (set) of elements.
33+ Relation(Box<Desc>),
34+ /// `(Red | Blue)` / `(Circle{radius Int} | Point)` — a sum type. Each
35+ /// constructor is a name plus its (possibly empty) payload fields.
36+ Variant(Vec<(String, Vec<(String, Desc)>)>),
37+}
38+
39+/// The scalar column types.
40+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41+pub enum Scalar {
42+ Int,
43+ Float,
44+ Text,
45+ Bool,
46+ Bytes,
47+ Uuid,
48+}
49+
50+impl Scalar {
51+ fn as_str(self) -> &'static str {
52+ match self {
53+ Scalar::Int => "Int",
54+ Scalar::Float => "Float",
55+ Scalar::Text => "Text",
56+ Scalar::Bool => "Bool",
57+ Scalar::Bytes => "Bytes",
58+ Scalar::Uuid => "Uuid",
59+ }
60+ }
61+
62+ fn from_str(s: &str) -> Option<Scalar> {
63+ match s {
64+ "Int" => Some(Scalar::Int),
65+ "Float" => Some(Scalar::Float),
66+ "Text" => Some(Scalar::Text),
67+ "Bool" => Some(Scalar::Bool),
68+ "Bytes" => Some(Scalar::Bytes),
69+ "Uuid" => Some(Scalar::Uuid),
70+ _ => None,
71+ }
72+ }
73+}
74+
75+/// A descriptor parse error: a byte offset plus a human-readable message.
76+#[derive(Debug, Clone, PartialEq, Eq)]
77+pub struct ParseError {
78+ pub pos: usize,
79+ pub msg: String,
80+}
81+
82+impl fmt::Display for ParseError {
83+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84+ write!(f, "descriptor parse error at byte {}: {}", self.pos, self.msg)
85+ }
86+}
87+
88+impl std::error::Error for ParseError {}
89+
90+/// Parse a schema-descriptor string into a `Desc`.
91+///
92+/// The input is treated as a single descriptor. Leading/trailing whitespace is
93+/// allowed; anything left over after one descriptor is an error.
94+pub fn parse(input: &str) -> Result<Desc, ParseError> {
95+ let mut p = Parser {
96+ chars: input.as_bytes(),
97+ pos: 0,
98+ };
99+ p.skip_ws();
100+ let d = p.parse_desc()?;
101+ p.skip_ws();
102+ if p.pos != p.chars.len() {
103+ return Err(p.err(format!("unexpected trailing input {:?}", p.rest())));
104+ }
105+ Ok(d)
106+}
107+
108+/// Render a `Desc` back to its canonical descriptor string. This is the exact
109+/// inverse of [`parse`] and matches the compiler's emitter byte-for-byte, so
110+/// `emit(parse(s)) == s` for any well-formed descriptor.
111+pub fn emit(d: &Desc) -> String {
112+ let mut out = String::new();
113+ emit_into(d, &mut out);
114+ out
115+}
116+
117+fn emit_into(d: &Desc, out: &mut String) {
118+ match d {
119+ Desc::Scalar(s) => out.push_str(s.as_str()),
120+ Desc::Record(fields) => {
121+ out.push('{');
122+ for (i, (name, fty)) in fields.iter().enumerate() {
123+ if i > 0 {
124+ out.push_str(" ");
125+ }
126+ out.push_str(name);
127+ out.push(' ');
128+ emit_into(fty, out);
129+ }
130+ out.push('}');
131+ }
132+ Desc::Relation(inner) => {
133+ out.push_str("(Rel ");
134+ emit_into(inner, out);
135+ out.push(')');
136+ }
137+ Desc::Variant(ctors) => {
138+ out.push('(');
139+ for (i, (name, fields)) in ctors.iter().enumerate() {
140+ if i > 0 {
141+ out.push_str(" | ");
142+ }
143+ out.push_str(name);
144+ if !fields.is_empty() {
145+ // Payload attaches directly: `Circle{radius Int}`.
146+ emit_into(&Desc::Record(fields.clone()), out);
147+ }
148+ }
149+ out.push(')');
150+ }
151+ }
152+}
153+
154+struct Parser<'a> {
155+ chars: &'a [u8],
156+ pos: usize,
157+}
158+
159+impl<'a> Parser<'a> {
160+ fn err(&self, msg: String) -> ParseError {
161+ ParseError { pos: self.pos, msg }
162+ }
163+
164+ fn rest(&self) -> String {
165+ String::from_utf8_lossy(&self.chars[self.pos..]).into_owned()
166+ }
167+
168+ fn peek(&self) -> Option<u8> {
169+ self.chars.get(self.pos).copied()
170+ }
171+
172+ fn skip_ws(&mut self) {
173+ while matches!(self.peek(), Some(b' ' | b'\t' | b'\n' | b'\r')) {
174+ self.pos += 1;
175+ }
176+ }
177+
178+ /// Skip whitespace, returning how many spaces were skipped. Callers use the
179+ /// count to enforce the record field separator (2+ spaces).
180+ fn skip_ws_count(&mut self) -> usize {
181+ let start = self.pos;
182+ self.skip_ws();
183+ self.pos - start
184+ }
185+
186+ fn expect(&mut self, c: u8) -> Result<(), ParseError> {
187+ if self.peek() == Some(c) {
188+ self.pos += 1;
189+ Ok(())
190+ } else {
191+ Err(self.err(format!(
192+ "expected {:?}, found {:?}",
193+ c as char,
194+ self.peek().map(|b| b as char)
195+ )))
196+ }
197+ }
198+
199+ fn eat_keyword(&mut self, kw: &str) -> bool {
200+ if self.chars[self.pos..].starts_with(kw.as_bytes()) {
201+ // Must not be followed by an identifier char (so `Rel` doesn't
202+ // match a field named `Relay`).
203+ let next = self.chars.get(self.pos + kw.len());
204+ if !matches!(next, Some(b) if b.is_ascii_alphanumeric() || *b == b'_') {
205+ self.pos += kw.len();
206+ return true;
207+ }
208+ }
209+ false
210+ }
211+
212+ fn parse_ident(&mut self) -> Result<String, ParseError> {
213+ let start = self.pos;
214+ while matches!(self.peek(), Some(b) if b.is_ascii_alphanumeric() || b == b'_') {
215+ self.pos += 1;
216+ }
217+ if self.pos == start {
218+ return Err(self.err(format!("expected an identifier, found {:?}", self.rest())));
219+ }
220+ Ok(String::from_utf8_lossy(&self.chars[start..self.pos]).into_owned())
221+ }
222+
223+ fn parse_desc(&mut self) -> Result<Desc, ParseError> {
224+ self.skip_ws();
225+ match self.peek() {
226+ Some(b'{') => self.parse_record().map(Desc::Record),
227+ Some(b'(') => self.parse_paren(),
228+ Some(_) => {
229+ let name = self.parse_ident()?;
230+ match Scalar::from_str(&name) {
231+ Some(s) => Ok(Desc::Scalar(s)),
232+ None => Err(self.err(format!(
233+ "unknown type {name:?} (expected a scalar, record, (Rel ..), or (A | B))"
234+ ))),
235+ }
236+ }
237+ None => Err(self.err("unexpected end of input".to_string())),
238+ }
239+ }
240+
241+ fn parse_record(&mut self) -> Result<Vec<(String, Desc)>, ParseError> {
242+ self.expect(b'{')?;
243+ let mut fields = Vec::new();
244+ self.skip_ws();
245+ if self.peek() == Some(b'}') {
246+ self.pos += 1;
247+ return Ok(fields);
248+ }
249+ loop {
250+ self.skip_ws();
251+ let name = self.parse_ident()?;
252+ // Field name and type are separated by a single space.
253+ self.skip_ws();
254+ let ty = self.parse_desc()?;
255+ fields.push((name, ty));
256+ // A record field boundary is 2+ spaces at depth 0; `}` ends it.
257+ let before = self.pos;
258+ let spaces = self.skip_ws_count();
259+ match self.peek() {
260+ Some(b'}') => {
261+ self.pos += 1;
262+ return Ok(fields);
263+ }
264+ Some(_) if spaces >= 2 => continue,
265+ Some(_) => {
266+ return Err(self.err(format!(
267+ "expected 2+ spaces or '}}' after field (found {:?})",
268+ self.rest()
269+ )));
270+ }
271+ None => {
272+ self.pos = before;
273+ return Err(self.err("unterminated record (missing '}')".to_string()));
274+ }
275+ }
276+ }
277+ }
278+
279+ /// `(Rel <desc>)` or a variant `(A | B)` / `(Circle{..} | Point)`.
280+ fn parse_paren(&mut self) -> Result<Desc, ParseError> {
281+ self.expect(b'(')?;
282+ self.skip_ws();
283+ if self.eat_keyword("Rel") {
284+ let inner = self.parse_desc()?;
285+ self.skip_ws();
286+ self.expect(b')')?;
287+ return Ok(Desc::Relation(Box::new(inner)));
288+ }
289+ // Variant: one or more ctors separated by ` | `.
290+ let mut ctors = Vec::new();
291+ loop {
292+ self.skip_ws();
293+ let name = self.parse_ident()?;
294+ // A payload record attaches with NO intervening space: `Circle{..}`.
295+ let fields = if self.peek() == Some(b'{') {
296+ self.parse_record()?
297+ } else {
298+ Vec::new()
299+ };
300+ ctors.push((name, fields));
301+ self.skip_ws();
302+ if self.peek() == Some(b'|') {
303+ self.pos += 1;
304+ continue;
305+ }
306+ self.expect(b')')?;
307+ return Ok(Desc::Variant(ctors));
308+ }
309+ }
310+}
311+
312+#[cfg(test)]
313+mod tests {
314+ use super::*;
315+
316+ fn roundtrip(s: &str) {
317+ let d = parse(s).unwrap_or_else(|e| panic!("parse failed for {s:?}: {e}"));
318+ let back = emit(&d);
319+ assert_eq!(back, s, "round-trip mismatch for {s:?}");
320+ }
321+
322+ #[test]
323+ fn scalars() {
324+ for s in ["Int", "Float", "Text", "Bool", "Bytes", "Uuid"] {
325+ roundtrip(s);
326+ }
327+ }
328+
329+ #[test]
330+ fn records() {
331+ roundtrip("{name Text}");
332+ roundtrip("{name Text age Int}");
333+ roundtrip("{name Text addr {city Text zip Int}}");
334+ roundtrip("{}"); // empty record
335+ }
336+
337+ #[test]
338+ fn relations() {
339+ roundtrip("(Rel Text)");
340+ roundtrip("(Rel {name Text})");
341+ roundtrip("{tags (Rel Text)}");
342+ roundtrip("{pets (Rel (Rel Int))}");
343+ }
344+
345+ #[test]
346+ fn variants() {
347+ roundtrip("(Red | Blue)");
348+ roundtrip("(Red | Green | Blue)");
349+ roundtrip("(Circle{radius Int} | Point)");
350+ roundtrip("(Circle{radius Int} | Rect{w Int h Int} | Point)");
351+ }
352+
353+ #[test]
354+ fn nested_combinations() {
355+ roundtrip("{name Text status (Active | Inactive) history (Rel {at Int})}");
356+ roundtrip("(Just{v Text} | Nothing)");
357+ }
358+
359+ #[test]
360+ fn rejects_garbage() {
361+ assert!(parse("").is_err());
362+ assert!(parse("Int Extra").is_err());
363+ assert!(parse("{name}").is_err());
364+ assert!(parse("{name Text").is_err());
365+ assert!(parse("(Rel Text").is_err());
366+ assert!(parse("Nope").is_err());
367+ assert!(parse("(Red Blue)").is_err()); // missing ` | ` separator
368+ assert!(parse("Circle{radius Int}").is_err()); // variant must be parenthesized
369+ }
370+
371+ #[test]
372+ fn rejects_unknown_scalar() {
373+ let e = parse("Missing").unwrap_err();
374+ assert!(e.msg.contains("unknown type"), "{e}");
375+ }
376+}
crates/knot-jit/src/lib.rs+151−40
@@ -10,6 +10,51 @@
1010use knot_compiler::codegen::{self, Codegen};
1111use knot_compiler::{desugar, infer, types};
1212
13+/// The runtime's type-erased code-handle vtable, mirrored here so knot-jit can
14+/// hand a JIT module's lifetime to `Value::Function`/`Value::IO` without
15+/// depending on knot-runtime (a Cargo cycle). Must match `CodeHandle`'s field
16+/// order exactly — the runtime reads it via the same layout.
17+#[repr(C)]
18+struct CodeHandleLayout {
19+ refcount: *const std::sync::atomic::AtomicUsize,
20+ free: unsafe extern "C" fn(*const std::sync::atomic::AtomicUsize),
21+}
22+
23+/// Owns a JIT module for the whole life of the closures it produced. A single
24+/// heap allocation carries the shared refcount, the module, and the vtable the
25+/// runtime reads. `free_jit_module` (the vtable's `free`) reconstitutes this
26+/// block and frees the module's code — called by the runtime when the last
27+/// `Function`/`IO` referencing the module is dropped.
28+///
29+/// `repr(C)` guarantees `refcount` is at offset 0, so `free_jit_module` can
30+/// recover the block pointer from the refcount address the runtime passes it.
31+#[repr(C)]
32+struct JitModuleBlock {
33+ refcount: std::sync::atomic::AtomicUsize,
34+ module: Option<cranelift_jit::JITModule>,
35+ handle: CodeHandleLayout,
36+}
37+
38+/// Vtable `free`: the runtime calls this with the block's `refcount` address
39+/// when the count reaches zero. Reclaims the module's code/data memory and the
40+/// block itself.
41+///
42+/// # Safety
43+/// `refcount` must be the `&block.refcount` of a live, boxed `JitModuleBlock`
44+/// whose refcount has just reached zero (no closure can reference it again).
45+unsafe extern "C" fn free_jit_module(refcount: *const std::sync::atomic::AtomicUsize) {
46+ // The refcount field is at offset 0 of the block, so the block pointer is
47+ // the refcount pointer (the struct's first field).
48+ let block = refcount as *mut JitModuleBlock;
49+ let block = unsafe { Box::from_raw(block) };
50+ if let Some(module) = block.module {
51+ // SAFETY: the refcount reached zero, so no Function/IO referencing this
52+ // module is alive; none of its code can run again.
53+ unsafe { module.free_memory() };
54+ }
55+ // block drops here, freeing the refcount + handle storage.
56+}
57+
1358/// The result of JIT-compiling a knot source string: the resolved entry
1459/// address plus the value produced by running it. The produced value is an
1560/// opaque pointer into the host runtime's arena (a `Value*` in knot-runtime's
@@ -22,11 +67,12 @@ pub struct CompiledValue {
2267 /// The inferred type of the compiled program's body (for the caller's
2368 /// `Maybe a` unification check). `None` if it couldn't be determined.
2469 pub ty: Option<String>,
25− /// Relations the snippet declares (`*name : T` in a `with` block), for the
26− /// caller's host-relation check: a snippet may only use relations the host
27− /// program defined. The JIT never runs the generated `main`'s source-init
28− /// (its entry is `knot_user_main`), so these are surfaced statically from
29− /// the snippet's type env rather than observed at runtime.
70+ /// Relations the snippet declares (`*name : T` in a `with` block), as
71+ /// `name\tdescriptor` pairs, for the caller's host-relation check: a
72+ /// snippet may only use relations the host program defined, with a matching
73+ /// shape. The JIT never runs the generated `main`'s source-init (its entry
74+ /// is `knot_user_main`), so these are surfaced statically from the snippet's
75+ /// type env rather than observed at runtime.
3076 pub relations: Vec<String>,
3177}
3278
@@ -54,17 +100,17 @@ impl std::error::Error for CompileError {}
54100/// checked against it on REAL types (`infer::ty_subsumes`); a `false` verdict
55101/// makes this return `Err` (the runtime then yields `Nothing`).
56102///
57−/// `is_code_free`, when `Some`, is the runtime's `knot_value_is_code_free`:
58−/// after the body runs, the JIT'd module is FREED (not leaked) when the
59−/// produced value holds no `Function`/`IO` — those carry `fn_ptr`s into the
60−/// module's code, so a code-bearing value forces the module to stay alive.
61−/// `None` → always keep the module (the const-eval path has no runtime to
62−/// scan with).
103+/// `set_code` is the runtime's `knot_value_set_code`: after the body runs, the
104+/// produced value is walked and every `Function`/`IO` in it is stamped with a
105+/// handle to this compile's JIT module, so the module's code stays mapped while
106+/// any closure referencing it is alive and is freed when the last one drops.
107+/// `None` (the const-eval path) leaves the module leaked — it has no runtime to
108+/// stamp with.
63109pub fn compile_and_run(
64110 source: &str,
65111 db: *mut std::ffi::c_void,
66112 expected_src: Option<&str>,
67− is_code_free: Option<unsafe extern "C" fn(*mut std::ffi::c_void) -> i32>,
113+ set_code: Option<unsafe extern "C" fn(*mut std::ffi::c_void, *const std::ffi::c_void)>,
68114) -> Result<CompiledValue, CompileError> {
69115 // ── Lex ──
70116 let lexer = knot::lexer::Lexer::new(source);
@@ -88,7 +134,7 @@ pub fn compile_and_run(
88134
89135 // ── Desugar + type env + infer ──
90136 desugar::desugar(&mut program);
91− let type_env = types::TypeEnv::from_program(&program);
137+ let mut type_env = types::TypeEnv::from_program(&program);
92138 // Run inference, subsuming the body type against the host's expected type
93139 // (if any) on real types inside `check` — the verdict is read after.
94140 let infer::CheckOutput {
@@ -144,6 +190,11 @@ pub fn compile_and_run(
144190 )));
145191 }
146192
193+ // Inference + subsumption passed: safe to derive the relation schemas the
194+ // host-shape check and codegen need (an unpersistable or dangling field
195+ // type was rejected above with a clean diagnostic).
196+ type_env.compute_schemas(&program);
197+
147198 // The program's body type, surfaced for the caller's `Maybe a` record (the
148199 // file body is inferred as the root `main`, not a named decl, so
149200 // `type_info` never holds it). Passed through verbatim — no descriptor
@@ -151,8 +202,47 @@ pub fn compile_and_run(
151202 // subsumption already ran on real `Ty`s inside `check`.
152203 let body_ty = file_body_type;
153204
154− // Relations the snippet declares (for the host-relation check).
155− let relations: Vec<String> = type_env.source_schemas.keys().cloned().collect();
205+ // Relations the snippet declares, as `name\tdescriptor` pairs (for the
206+ // host's relation check). The descriptor is the canonical structural schema
207+ // string, so the runtime can compare the snippet's declared shape against
208+ // the host's registered schema — not just the name.
209+ let relations: Vec<String> = type_env
210+ .source_schemas
211+ .iter()
212+ .map(|(name, desc)| format!("{name}\t{desc}"))
213+ .collect();
214+
215+ // Validate every declared relation against the host BEFORE running the
216+ // snippet: the snippet's `main` executes effects, so a foreign or
217+ // shape-drifted relation would otherwise panic at the SQL layer mid-run.
218+ // The callback resolves the runtime's registered schema for the name.
219+ unsafe extern "C" {
220+ fn knot_compile_host_rel_matches(
221+ name_ptr: *const u8,
222+ name_len: usize,
223+ desc_ptr: *const u8,
224+ desc_len: usize,
225+ ) -> i32;
226+ }
227+ for (name, desc) in &type_env.source_schemas {
228+ let verdict = unsafe {
229+ knot_compile_host_rel_matches(name.as_ptr(), name.len(), desc.as_ptr(), desc.len())
230+ };
231+ match verdict {
232+ 0 => {}
233+ 1 => {
234+ return Err(CompileError(format!(
235+ "snippet's schema for relation `{name}` doesn't match the host's \
236+ (expected a different shape than the host's `{name}`)"
237+ )))
238+ }
239+ _ => {
240+ return Err(CompileError(format!(
241+ "snippet declares a relation `{name}` the host program doesn't have"
242+ )))
243+ }
244+ }
245+ }
156246
157247 // ── Codegen into a JITModule, on a grown stack ──
158248 // JITModule is not Send, so it can't cross stack::grow's thread boundary.
@@ -203,38 +293,59 @@ pub fn compile_and_run(
203293 )
204294 .map_err(|diags| CompileError(render_diags(&diags, source)))?;
205295 let (entry, module) = cg.finish_jit();
206− // Keep the module alive (boxed) until the produced value is scanned.
207− // A raw pointer crosses the `stack::grow` boundary because JITModule is
208− // not Send; it is reconstituted and either freed or leaked below.
209− let module = Box::into_raw(Box::new(module)) as usize;
210− Ok((entry as usize, module))
296+ // Move the module into a refcounted block that outlives codegen. A raw
297+ // pointer crosses the `stack::grow` boundary because JITModule is not
298+ // Send; it is reconstituted below and handed to the runtime.
299+ let block = Box::new(JitModuleBlock {
300+ refcount: std::sync::atomic::AtomicUsize::new(0),
301+ module: Some(module),
302+ handle: CodeHandleLayout {
303+ // Filled in after the box is placed, so the address is stable.
304+ refcount: std::ptr::null(),
305+ free: free_jit_module,
306+ },
307+ });
308+ let block = Box::into_raw(block);
309+ // Now that the block has its final address, point the handle's refcount
310+ // field at the block's counter.
311+ unsafe {
312+ (*block).handle.refcount = &raw const (*block).refcount;
313+ }
314+ Ok((entry as usize, block as usize))
211315 })?;
212− let (entry, module_raw) = entry_and_module;
316+ let (entry, block_raw) = entry_and_module;
213317
214318 // ── Call knot_user_main(db) -> Value* ──
215319 let entry_fn: extern "C" fn(*mut std::ffi::c_void) -> *mut std::ffi::c_void =
216320 unsafe { std::mem::transmute(entry) };
217321 let value = entry_fn(db);
218322
219− // ── Free the JIT module when the value can't re-enter its code ──
220− // A `Function`/`IO` value holds a `fn_ptr` into the module's code segment,
221− // so the module must stay alive while such a value is reachable. A pure
222− // data result (no lambdas, no IO thunks) can never call back into the
223− // module, so its code/data memory is freed here instead of leaked — this is
224− // what keeps a `compile` loop from growing code memory without bound.
225− let module = unsafe { Box::from_raw(module_raw as *mut cranelift_jit::JITModule) };
226− let code_free = match is_code_free {
227− Some(scan) => unsafe { scan(value) != 0 },
228− // No runtime to scan with (const-eval): conservatively keep the module.
229− None => false,
230− };
231− if code_free {
232− // SAFETY: the scan proved the value holds no `Function`/`IO`, so no
233− // reachable `fn_ptr` refers into this module; `knot_user_main` has
234− // returned and won't run again.
235− unsafe { module.free_memory() };
236− } else {
237− std::mem::forget(module);
323+ // ── Hand the module's lifetime to the returned value ──
324+ // Stamp every Function/IO in the result with this compile's code handle;
325+ // the runtime frees the module when the last such value drops. If the value
326+ // carries no code, nothing is stamped and the module is freed here.
327+ let block = block_raw as *mut JitModuleBlock;
328+ match set_code {
329+ Some(set_code) => unsafe {
330+ set_code(value, &raw const (*block).handle as *const std::ffi::c_void);
331+ // If no closure referenced the module, the refcount is still 0 —
332+ // free the module's code and the block now rather than leak.
333+ if (*block).refcount.load(std::sync::atomic::Ordering::Acquire) == 0 {
334+ let block = Box::from_raw(block);
335+ if let Some(module) = block.module {
336+ // SAFETY: refcount 0 means no Function/IO points into the
337+ // module; `knot_user_main` has returned and won't run again.
338+ module.free_memory();
339+ }
340+ }
341+ // Otherwise the block is now owned by the stamped values; their
342+ // destructors drive the count to zero and free_jit_module reclaims
343+ // it. Intentionally not freed here.
344+ },
345+ // No runtime to stamp with (const-eval): leak the module as before.
346+ None => {
347+ std::mem::forget(unsafe { Box::from_raw(block) });
348+ }
238349 }
239350
240351 Ok(CompiledValue {
crates/knot-jit/tests/cli_overrides.rs+567−1
@@ -381,6 +381,320 @@ Rel (Int 1) #ports []
381381 assert_ne!(code, 0, "expected element parse failure, stderr: {err}");
382382}
383383
384+// ── Vec and List: same flag syntax as Rel, different value semantics ────────
385+
386+#[test]
387+fn rel_dedups_vec_keeps_order_and_dups() {
388+ // A Rel is a set: duplicate elements collapse. A Vec is ordered and keeps
389+ // duplicates. Same flag syntax (`--tags=a,b,a`), different value.
390+ let dir = TempDir::fresh("rel_dedup");
391+ build(
392+ &dir,
393+ "rel_dedup",
394+ r#"with {
395+Rel Text #tags []
396+}
397+(|
398+ base.println (base.show tags)
399+ yield {})"#,
400+ );
401+ let (out, err, code) = run_with_args(&dir, "rel_dedup", &["--tags=a,b,a"]);
402+ assert_eq!(code, 0, "stderr: {err}");
403+ assert_eq!(out.trim_end(), "\"[a, b]\"\n{}");
404+
405+ let dir = TempDir::fresh("vec_dups");
406+ build(
407+ &dir,
408+ "vec_dups",
409+ r#"with {
410+Vec Text #tags (base.the (Vec Text) [])
411+}
412+(|
413+ base.println (base.show tags)
414+ yield {})"#,
415+ );
416+ let (out, err, code) = run_with_args(&dir, "vec_dups", &["--tags=a,b,a"]);
417+ assert_eq!(code, 0, "stderr: {err}");
418+ assert_eq!(out.trim_end(), "\"[a, b, a]\"\n{}");
419+}
420+
421+#[test]
422+fn vec_int_repeated_flags() {
423+ let dir = TempDir::fresh("vec_int");
424+ build(
425+ &dir,
426+ "vec_int",
427+ r#"with {
428+Vec (Int 1) #nums (base.the (Vec (Int 1)) [])
429+}
430+(|
431+ base.println (base.show nums)
432+ yield {})"#,
433+ );
434+ let (out, err, code) = run_with_args(&dir, "vec_int", &["--nums=1,2", "--nums=3"]);
435+ assert_eq!(code, 0, "stderr: {err}");
436+ assert_eq!(out.trim_end(), "\"[1, 2, 3]\"\n{}");
437+}
438+
439+#[test]
440+fn list_text_builds_cons_chain() {
441+ let dir = TempDir::fresh("list_text");
442+ build(
443+ &dir,
444+ "list_text",
445+ r#"with {
446+List Text #tags (List.Nil {})
447+}
448+(|
449+ base.println (base.show tags)
450+ yield {})"#,
451+ );
452+ let (out, err, code) = run_with_args(&dir, "list_text", &["--tags=a,b"]);
453+ assert_eq!(code, 0, "stderr: {err}");
454+ assert_eq!(
455+ out.trim_end(),
456+ "\"Cons {head a tail Cons {head b tail Nil}}\"\n{}"
457+ );
458+}
459+
460+#[test]
461+fn list_int_empty_flag_is_nil() {
462+ let dir = TempDir::fresh("list_empty");
463+ build(
464+ &dir,
465+ "list_empty",
466+ r#"with {
467+List (Int 1) #nums (List.Nil {})
468+}
469+(|
470+ base.println (base.show nums)
471+ yield {})"#,
472+ );
473+ let (out, err, code) = run_with_args(&dir, "list_empty", &["--nums="]);
474+ assert_eq!(code, 0, "stderr: {err}");
475+ assert_eq!(out.trim_end(), "\"Nil\"\n{}");
476+}
477+
478+#[test]
479+fn vec_bool_bare_flag() {
480+ let dir = TempDir::fresh("vec_bool");
481+ build(
482+ &dir,
483+ "vec_bool",
484+ r#"with {
485+Vec Bool #flags (base.the (Vec Bool) [])
486+}
487+(|
488+ base.println (base.show flags)
489+ yield {})"#,
490+ );
491+ let (out, err, code) = run_with_args(&dir, "vec_bool", &["--flags"]);
492+ assert_eq!(code, 0, "stderr: {err}");
493+ assert_eq!(out.trim_end(), "\"[True]\"\n{}");
494+}
495+
496+#[test]
497+fn maybe_vec_override_and_nothing() {
498+ let dir = TempDir::fresh("maybe_vec");
499+ build(
500+ &dir,
501+ "maybe_vec",
502+ r#"with {
503+Maybe (Vec Text) #tags (Maybe.Nothing {})
504+}
505+(|
506+ base.println (base.show tags)
507+ yield {})"#,
508+ );
509+ // A Maybe flag wraps the parsed value in Just.
510+ let (out, err, code) = run_with_args(&dir, "maybe_vec", &["--tags=a,b"]);
511+ assert_eq!(code, 0, "stderr: {err}");
512+ assert_eq!(out.trim_end(), "\"Just {value [a, b]}\"\n{}");
513+ // No flag: the Nothing default applies.
514+ let (out, err, code) = run_with_args(&dir, "maybe_vec", &[]);
515+ assert_eq!(code, 0, "stderr: {err}");
516+ assert_eq!(out.trim_end(), "\"Nothing\"\n{}");
517+}
518+
519+// ── Collections of the extended element types (Bytes, Uuid, enum, refined) ──
520+
521+#[test]
522+fn vec_of_bytes() {
523+ let dir = TempDir::fresh("vec_bytes");
524+ build(
525+ &dir,
526+ "vec_bytes",
527+ r#"with {
528+Vec Bytes #keys (base.the (Vec Bytes) [])
529+}
530+(|
531+ base.println (base.show keys)
532+ yield {})"#,
533+ );
534+ let (out, err, code) = run_with_args(&dir, "vec_bytes", &["--keys=3q2-7w,AAAA"]);
535+ assert_eq!(code, 0, "stderr: {err}");
536+ assert_eq!(out.trim_end(), "\"[deadbeef, 000000]\"\n{}");
537+}
538+
539+#[test]
540+fn vec_of_bytes_bad_element_aborts() {
541+ let dir = TempDir::fresh("vec_bytes_bad");
542+ build(
543+ &dir,
544+ "vec_bytes_bad",
545+ r#"with {
546+Vec Bytes #keys (base.the (Vec Bytes) [])
547+}
548+(|
549+ base.println (base.show keys)
550+ yield {})"#,
551+ );
552+ let (_, err, code) = run_with_args(&dir, "vec_bytes_bad", &["--keys=3q2+7w=="]);
553+ assert_ne!(code, 0, "expected element parse failure, stderr: {err}");
554+}
555+
556+#[test]
557+fn vec_of_uuid() {
558+ let dir = TempDir::fresh("vec_uuid");
559+ build(
560+ &dir,
561+ "vec_uuid",
562+ r#"with {
563+Vec Uuid #ids (base.the (Vec Uuid) [])
564+}
565+(|
566+ base.println (base.show ids)
567+ yield {})"#,
568+ );
569+ let (out, err, code) = run_with_args(
570+ &dir,
571+ "vec_uuid",
572+ &["--ids=550e8400-e29b-41d4-a716-446655440000"],
573+ );
574+ assert_eq!(code, 0, "stderr: {err}");
575+ assert_eq!(
576+ out.trim_end(),
577+ "\"[550e8400-e29b-41d4-a716-446655440000]\"\n{}"
578+ );
579+}
580+
581+#[test]
582+fn vec_of_enum() {
583+ let dir = TempDir::fresh("vec_enum");
584+ build(
585+ &dir,
586+ "vec_enum",
587+ r#"with {
588+Mode Dev {} Prod {}
589+Vec Mode #modes (base.the (Vec Mode) [])
590+}
591+(|
592+ base.println (base.show modes)
593+ yield {})"#,
594+ );
595+ let (out, err, code) = run_with_args(&dir, "vec_enum", &["--modes=Dev,Prod"]);
596+ assert_eq!(code, 0, "stderr: {err}");
597+ assert_eq!(out.trim_end(), "\"[Dev, Prod]\"\n{}");
598+}
599+
600+#[test]
601+fn rel_of_enum_dedups() {
602+ let dir = TempDir::fresh("rel_enum");
603+ build(
604+ &dir,
605+ "rel_enum",
606+ r#"with {
607+Mode Dev {} Prod {}
608+Rel Mode #modes []
609+}
610+(|
611+ base.println (base.show modes)
612+ yield {})"#,
613+ );
614+ let (out, err, code) = run_with_args(&dir, "rel_enum", &["--modes=Dev,Prod,Dev"]);
615+ assert_eq!(code, 0, "stderr: {err}");
616+ assert_eq!(out.trim_end(), "\"[Dev, Prod]\"\n{}");
617+}
618+
619+#[test]
620+fn vec_of_enum_bad_ctor_aborts() {
621+ let dir = TempDir::fresh("vec_enum_bad");
622+ build(
623+ &dir,
624+ "vec_enum_bad",
625+ r#"with {
626+Mode Dev {} Prod {}
627+Vec Mode #modes (base.the (Vec Mode) [])
628+}
629+(|
630+ base.println (base.show modes)
631+ yield {})"#,
632+ );
633+ let (_, err, code) = run_with_args(&dir, "vec_enum_bad", &["--modes=Staging"]);
634+ assert_ne!(code, 0, "expected unknown-constructor error, stderr: {err}");
635+}
636+
637+#[test]
638+fn vec_of_refined_checks_each_element() {
639+ let dir = TempDir::fresh("vec_refined");
640+ build(
641+ &dir,
642+ "vec_refined",
643+ r#"with {
644+Pos Int 1 \x -> x > 0
645+Vec Pos #nums (base.the (Vec Pos) [])
646+}
647+(|
648+ base.println (base.show nums)
649+ yield {})"#,
650+ );
651+ let (out, err, code) = run_with_args(&dir, "vec_refined", &["--nums=1,2"]);
652+ assert_eq!(code, 0, "stderr: {err}");
653+ assert_eq!(out.trim_end(), "\"[1, 2]\"\n{}");
654+ // A failing element aborts, naming the flag and type.
655+ let (_, err, code) = run_with_args(&dir, "vec_refined", &["--nums=1,-5"]);
656+ assert_ne!(code, 0, "expected predicate failure, stderr: {err}");
657+ assert!(err.contains("does not satisfy predicate"), "stderr: {err}");
658+}
659+
660+#[test]
661+fn list_of_refined_checks_each_element() {
662+ let dir = TempDir::fresh("list_refined");
663+ build(
664+ &dir,
665+ "list_refined",
666+ r#"with {
667+Pos Int 1 \x -> x > 0
668+List Pos #nums (List.Nil {})
669+}
670+(|
671+ base.println (base.show nums)
672+ yield {})"#,
673+ );
674+ let (_, err, code) = run_with_args(&dir, "list_refined", &["--nums=1,-5"]);
675+ assert_ne!(code, 0, "expected predicate failure, stderr: {err}");
676+ assert!(err.contains("does not satisfy predicate"), "stderr: {err}");
677+}
678+
679+#[test]
680+fn maybe_vec_of_refined_checks_each_element() {
681+ let dir = TempDir::fresh("maybe_vec_refined");
682+ build(
683+ &dir,
684+ "maybe_vec_refined",
685+ r#"with {
686+Pos Int 1 \x -> x > 0
687+Maybe (Vec Pos) #nums (Maybe.Nothing {})
688+}
689+(|
690+ base.println (base.show nums)
691+ yield {})"#,
692+ );
693+ let (_, err, code) = run_with_args(&dir, "maybe_vec_refined", &["--nums=1,-5"]);
694+ assert_ne!(code, 0, "expected predicate failure, stderr: {err}");
695+ assert!(err.contains("does not satisfy predicate"), "stderr: {err}");
696+}
697+
384698// ── Maybe variants of the new types ─────────────────────────────────────────
385699
386700#[test]
@@ -402,7 +716,8 @@ Maybe Mode #mode (Maybe.Nothing {})
402716 let (out, err, code) = run_with_args(&dir, "maybe_enum", &["--mode=Prod"]);
403717 assert_eq!(code, 0, "stderr: {err}");
404718 assert_eq!(out.trim_end(), "\"prod\"\n{}");
405− let (out, err, code) = run_with_args(&dir, "maybe_enum", &["--mode=Nothing"]);
719+ // No flag: the Nothing default applies.
720+ let (out, err, code) = run_with_args(&dir, "maybe_enum", &[]);
406721 assert_eq!(code, 0, "stderr: {err}");
407722 assert_eq!(out.trim_end(), "\"none\"\n{}");
408723}
@@ -414,3 +729,254 @@ Maybe Mode #mode (Maybe.Nothing {})
414729// `with`-field loop compiles the field's body inline rather than calling the
415730// global fn that would consult the override. That pre-existing gap is out of
416731// scope here; the runtime CLI path (`#`-args) is covered above.
732+
733+// ── Repeated `#`-argument declarations ──────────────────────────────────────
734+//
735+// A `#name` may be declared in multiple scopes; every declaration refers to the
736+// same (global, name-keyed) CLI argument, so they must agree on type and
737+// default. Two concrete defaults must be equal literals; a concrete default may
738+// pair with any number of `_` (required) declarations.
739+
740+/// Build `src`, expecting failure with `needle` in stderr.
741+fn build_err(dir: &TempDir, name: &str, src: &str, needle: &str) {
742+ let src_path = dir.path().join(format!("{name}.knot"));
743+ std::fs::write(&src_path, src).unwrap();
744+ let out = Command::new(e2e::knot_bin())
745+ .arg("build")
746+ .arg(&src_path)
747+ .arg("-o")
748+ .arg(dir.path().join(name))
749+ .output()
750+ .expect("failed to run knot build");
751+ assert!(
752+ !out.status.success(),
753+ "expected build of {name} to fail, but it succeeded"
754+ );
755+ let err = String::from_utf8_lossy(&out.stderr);
756+ assert!(
757+ err.contains(needle),
758+ "error for {name} did not contain `{needle}`:\n{err}"
759+ );
760+}
761+
762+#[test]
763+fn duplicate_arg_in_one_record_is_rejected() {
764+ let dir = TempDir::fresh("dup_arg_record");
765+ build_err(
766+ &dir,
767+ "dup_arg_record",
768+ r#"with {
769+Port Int 1
770+Port #port 8080
771+Port #port 9090
772+}
773+(|
774+ base.println port
775+ yield {})"#,
776+ "declared more than once",
777+ );
778+}
779+
780+#[test]
781+fn repeated_arg_agreeing_literals_allowed() {
782+ let dir = TempDir::fresh("repeat_agree");
783+ build(
784+ &dir,
785+ "repeat_agree",
786+ r#"with {
787+Port Int 1
788+Port #port 8080
789+}
790+(with {
791+Port #port 8080
792+}
793+(|
794+ base.println port
795+ yield {}))"#,
796+ );
797+ let (out, err, code) = run_with_args(&dir, "repeat_agree", &[]);
798+ assert_eq!(code, 0, "stderr: {err}");
799+ assert_eq!(out.trim_end(), "8080\n{}");
800+}
801+
802+#[test]
803+fn repeated_arg_different_defaults_rejected() {
804+ let dir = TempDir::fresh("repeat_diff");
805+ build_err(
806+ &dir,
807+ "repeat_diff",
808+ r#"with {
809+Port Int 1
810+Port #port 8080
811+}
812+(with {
813+Port #port 9090
814+}
815+(|
816+ base.println port
817+ yield {}))"#,
818+ "different defaults",
819+ );
820+}
821+
822+#[test]
823+fn repeated_arg_different_types_rejected() {
824+ let dir = TempDir::fresh("repeat_types");
825+ build_err(
826+ &dir,
827+ "repeat_types",
828+ r#"with {
829+Port Int 1
830+Port #port 8080
831+}
832+(with {
833+Text #port "hello"
834+}
835+(|
836+ base.println (base.show port)
837+ yield {}))"#,
838+ "different types",
839+ );
840+}
841+
842+#[test]
843+fn repeated_arg_computed_default_rejected() {
844+ let dir = TempDir::fresh("repeat_computed");
845+ build_err(
846+ &dir,
847+ "repeat_computed",
848+ r#"with {
849+Port Int 1
850+Port #port 8080
851+}
852+(with {
853+Port #port (4 * 2020)
854+}
855+(|
856+ base.println port
857+ yield {}))"#,
858+ "must be a literal",
859+ );
860+}
861+
862+// ── Gap closures ────────────────────────────────────────────────────────────
863+
864+#[test]
865+fn non_settable_arg_with_flag_aborts() {
866+ // A record `#`-arg isn't CLI-settable. Passing the flag must abort loudly,
867+ // not silently fall through to the default.
868+ let dir = TempDir::fresh("record_forbid");
869+ build(
870+ &dir,
871+ "record_forbid",
872+ r#"with {
873+Cfg {name Text}
874+Cfg #cfg {name "default"}
875+}
876+(|
877+ base.println cfg.name
878+ yield {})"#,
879+ );
880+ // No flag: the default applies.
881+ let (out, err, code) = run_with_args(&dir, "record_forbid", &[]);
882+ assert_eq!(code, 0, "stderr: {err}");
883+ assert_eq!(out.trim_end(), "\"default\"\n{}");
884+ // Flag present: loud error, not a silent no-op.
885+ let (_, err, code) =
886+ run_with_args(&dir, "record_forbid", &["--cfg={name \"x\"}"]);
887+ assert_ne!(code, 0, "expected a loud error, stderr: {err}");
888+ assert!(
889+ err.contains("cannot be set from the command line"),
890+ "stderr: {err}"
891+ );
892+}
893+
894+#[test]
895+fn collection_of_non_scalar_with_flag_aborts() {
896+ // A collection of a NON-CLI-parseable element (a record) isn't settable. A
897+ // passed flag must abort loudly, not silently fall through to the default.
898+ let dir = TempDir::fresh("vec_record_forbid");
899+ build(
900+ &dir,
901+ "vec_record_forbid",
902+ r#"with {
903+Pt {x Int 1}
904+Vec Pt #pts (base.the (Vec Pt) [])
905+}
906+(|
907+ base.println "hi"
908+ yield {})"#,
909+ );
910+ let (_, err, code) = run_with_args(&dir, "vec_record_forbid", &["--pts=x"]);
911+ assert_ne!(code, 0, "expected a loud error, stderr: {err}");
912+ assert!(
913+ err.contains("cannot be set from the command line"),
914+ "stderr: {err}"
915+ );
916+}
917+
918+#[test]
919+fn required_underscore_uses_sibling_default() {
920+ // `#port _` (required) in one scope + `#port 8080` (concrete default) in
921+ // another: the concrete default wins when the flag is absent, in both
922+ // declaration orders. The flag still wins when present.
923+ for (name, src) in [
924+ (
925+ "uscore_outer",
926+ r#"with {
927+Port Int 1
928+Port #port _
929+}
930+(with {
931+Port #port 8080
932+}
933+(|
934+ base.println port
935+ yield {}))"#,
936+ ),
937+ (
938+ "uscore_inner",
939+ r#"with {
940+Port Int 1
941+Port #port 8080
942+}
943+(with {
944+Port #port _
945+}
946+(|
947+ base.println port
948+ yield {}))"#,
949+ ),
950+ ] {
951+ let dir = TempDir::fresh(name);
952+ build(&dir, name, src);
953+ // No flag: the concrete default applies (not a missing-argument abort).
954+ let (out, err, code) = run_with_args(&dir, name, &[]);
955+ assert_eq!(code, 0, "{name}: stderr: {err}");
956+ assert_eq!(out.trim_end(), "8080\n{}", "{name}");
957+ // Flag present: the flag wins.
958+ let (out, err, code) = run_with_args(&dir, name, &["--port=9090"]);
959+ assert_eq!(code, 0, "{name}: stderr: {err}");
960+ assert_eq!(out.trim_end(), "9090\n{}", "{name}");
961+ }
962+}
963+
964+#[test]
965+fn truly_required_underscore_still_aborts() {
966+ // A `_` arg with NO concrete default anywhere stays required.
967+ let dir = TempDir::fresh("uscore_required");
968+ build(
969+ &dir,
970+ "uscore_required",
971+ r#"with {
972+Port Int 1
973+Port #port _
974+}
975+(|
976+ base.println port
977+ yield {})"#,
978+ );
979+ let (_, err, code) = run_with_args(&dir, "uscore_required", &[]);
980+ assert_ne!(code, 0, "expected a missing-argument abort, stderr: {err}");
981+ assert!(err.contains("missing required argument"), "stderr: {err}");
982+}
crates/knot-jit/tests/jit_e2e.rs+64−15
@@ -20,11 +20,10 @@ fn db() -> *mut std::ffi::c_void {
2020 knot_runtime::knot_db_open(PATH.as_ptr(), PATH.len())
2121}
2222
23−/// The runtime's code-free scan, passed so the JIT frees modules whose result
24−/// is pure data. These tests read the result back as a value (never call a
25−/// returned lambda), so a freed module is never re-entered.
26−unsafe extern "C" fn scan(v: *mut std::ffi::c_void) -> i32 {
27− knot_runtime::knot_value_is_code_free(v.cast())
23+/// The runtime's code-stamping hook, passed so the JIT attaches each returned
24+/// closure's module handle (refcounted module lifetime).
25+unsafe extern "C" fn scan(v: *mut std::ffi::c_void, handle: *const std::ffi::c_void) {
26+ unsafe { knot_runtime::knot_value_set_code(v.cast(), handle.cast()) }
2827}
2928
3029#[test]
@@ -81,9 +80,9 @@ fn jit_surfaces_text_body_type() {
8180 assert_eq!(out.ty.as_deref(), Some("Text"));
8281}
8382
84−/// A pure-data result lets the JIT free the module (the scan reports
85−/// code-free). The value must stay valid after the free — its content is pure
86−/// data, not code. This exercises the `free_memory` path directly.
83+/// A pure-data result carries no Function/IO, so nothing references the JIT
84+/// module: its refcount stays 0 and the module is freed before
85+/// `compile_and_run` returns. The value stays valid — it's pure data, not code.
8786#[test]
8887fn jit_frees_module_for_pure_data_result() {
8988 use knot_runtime::Value;
@@ -91,7 +90,7 @@ fn jit_frees_module_for_pure_data_result() {
9190 let db = db();
9291 let out = compile_and_run("{n 42 label \"answer\"}", db, None, Some(scan))
9392 .expect("JIT compile failed");
94− // Read the record AFTER the module was freed inside compile_and_run.
93+ // Read the record AFTER the (code-free) module was reclaimed.
9594 match unsafe { &*out.value.cast::<Value>() } {
9695 Value::Record(fields) => {
9796 assert_eq!(fields.len(), 2);
@@ -105,23 +104,73 @@ fn jit_frees_module_for_pure_data_result() {
105104 }
106105}
107106
108−/// A function-returning result keeps the module alive: the scan reports the
109−/// value holds code, so the JIT must NOT free it. The surfaced value is a
110−/// callable Function whose `fn_ptr` points into the (still-mapped) module.
107+/// A function-returning result is stamped with the module's code handle: the
108+/// refcounted handle keeps the module mapped while the closure is alive. The
109+/// surfaced Function carries a non-null `code` handle and a live `fn_ptr`.
111110#[test]
112111fn jit_keeps_module_for_function_result() {
113112 use knot_runtime::Value;
114113 init_compile_rt();
115114 let db = db();
116115 let out = compile_and_run("\\x -> x + 1", db, None, Some(scan)).expect("JIT compile failed");
117− // The scan saw a Function, so the module was kept (leaked) — and the value
118− // is a real Function value carrying a non-null code pointer.
119116 match unsafe { &*out.value.cast::<Value>() } {
120− Value::Function(f) => assert!(!f.fn_ptr.is_null(), "function must hold a code ptr"),
117+ Value::Function(f) => {
118+ assert!(!f.fn_ptr.is_null(), "function must hold a code ptr");
119+ assert!(!f.code.is_null(), "function must carry the module handle");
120+ }
121121 _ => panic!("expected a Function"),
122122 }
123123}
124124
125+/// The whole point of refcounting the module: a closure returned from
126+/// `compile` stays callable *after* compile returns, because its code handle
127+/// keeps the module mapped. Apply it through the runtime and check the result —
128+/// if the module had been freed, this would jump into unmapped memory.
129+#[test]
130+fn jit_returned_closure_stays_callable() {
131+ use knot_runtime::Value;
132+ init_compile_rt();
133+ let db = db();
134+ let out = compile_and_run("\\x -> x + 20", db, None, Some(scan)).expect("JIT compile failed");
135+ let f = out.value.cast::<Value>();
136+ assert!(matches!(unsafe { &*f }, Value::Function(_)), "expected a Function");
137+ // Apply the closure to 1: (x + 20) 1 = 21. knot_value_call(db, fn, arg)
138+ // runs the module's fn_ptr — only possible because the module is mapped.
139+ let arg = knot_runtime::knot_value_int(1);
140+ let result = knot_runtime::knot_value_call(db, f, arg);
141+ match unsafe { &*result } {
142+ Value::Int(n) => assert_eq!(*n, 21),
143+ _ => panic!("expected Int result from applying the closure"),
144+ }
145+}
146+
147+/// A closure that is never kept alive is reclaimed by the arena reset, which
148+/// runs its destructor and drops the module refcount to zero. A second compile
149+/// of the same source then still produces a working closure — the first
150+/// module's reclamation didn't wedge the JIT or corrupt the allocator.
151+#[test]
152+fn jit_dropped_closure_frees_module() {
153+ use knot_runtime::Value;
154+ init_compile_rt();
155+ let db = db();
156+ // Compile a closure, then reset the arena — dropping every value it holds,
157+ // including the closure and its module reference.
158+ let mark = knot_runtime::knot_arena_mark();
159+ {
160+ let out = compile_and_run("\\x -> x + 1", db, None, Some(scan)).expect("JIT compile failed");
161+ assert!(matches!(unsafe { &*out.value.cast::<Value>() }, Value::Function(_)));
162+ knot_runtime::knot_arena_reset_to(mark);
163+ }
164+ // A fresh compile of the same source must still produce a working closure.
165+ let out2 = compile_and_run("\\x -> x + 1", db, None, Some(scan)).expect("second JIT compile failed");
166+ let arg = knot_runtime::knot_value_int(41);
167+ let result = knot_runtime::knot_value_call(db, out2.value.cast(), arg);
168+ match unsafe { &*result } {
169+ Value::Int(n) => assert_eq!(*n, 42),
170+ _ => panic!("expected Int result"),
171+ }
172+}
173+
125174/// A user ADT extracted in the JIT renders self-contained: the declaration is
126175/// inlined (`with {…} (Type.Ctor …)`), matching the compiled-binary path.
127176/// Regression: the ctor-decl registration lived in the C `main` prologue,
crates/knot-jit/tests/migration.rs+36−2
@@ -257,10 +257,12 @@ fn migrate_committed_using_dangling_type() {
257257 lock_in_dir(dir.path(), "mig_dangle");
258258
259259 // Delete the `Active` data type entirely — but the committed migration's
260− // migration lambda (`Active.Yes {}`) still references it.
260+ // migration lambda (`Active.Yes {}`) still references it. The source's own
261+ // `active` field is retyped to `Text` so only the *migration* dangles; a
262+ // dangling field in the source decl is a separate error (unknown type).
261263 let deleted = r#"with {
262264PersonV1 {name Text}
263−PersonV2 {name Text active Active}
265+PersonV2 {name Text active Text}
264266Rel PersonV2 *people
265267}
266268(|
@@ -288,6 +290,38 @@ Rel PersonV2 *people
288290 );
289291}
290292
293+/// A source record field typed by a name that isn't declared (a builtin, an
294+/// alias, or a data type) is a dangling reference. It used to slip through to
295+/// the schema descriptor and be silently typed `Text`; now it's a clean
296+/// resolution error that names the type and fails the build.
297+#[test]
298+fn source_field_with_dangling_type_is_rejected() {
299+ let dir = dir_for("dangle_field");
300+ let src = r#"with {
301+Person {name Text active Active}
302+Rel Person *people
303+}
304+(|
305+ yield {})"#;
306+ std::fs::write(dir.join("dangle_field.knot"), src).unwrap();
307+ let build = std::process::Command::new(knot_bin())
308+ .arg("build")
309+ .arg(dir.join("dangle_field.knot"))
310+ .arg("-o")
311+ .arg(dir.join("dangle_field"))
312+ .output()
313+ .expect("knot build");
314+ assert!(
315+ !build.status.success(),
316+ "a field typed by an undeclared name must fail the build"
317+ );
318+ let stderr = String::from_utf8_lossy(&build.stderr);
319+ assert!(
320+ stderr.contains("unknown type 'Active'"),
321+ "error names the undeclared type: {stderr}"
322+ );
323+}
324+
291325/// A source may stage at most one pending migration (its `from` is derived from
292326/// the lock, which is unambiguous only for a single step). Two `migrate` clauses
293327/// on one source are a build error — and `knot lock` rejects them too (both parse
crates/knot-jit/tests/persistence.rs+304−223
@@ -3,7 +3,18 @@
33//! process-level effect). Builds and runs real binaries.
44
55mod e2e;
6−use e2e::assert_stdout;
6+use e2e::{assert_stdout, run_program};
7+
8+/// Line-set comparison (order-independent): forked output interleaves.
9+fn assert_stdout_unordered(name: &str, src: &str, expected: &[&str]) {
10+ let (out, err, code) = run_program(name, src);
11+ assert_eq!(code, 0, "program {name} exited {code}\nstderr:\n{err}");
12+ let mut got: Vec<&str> = out.trim_end().lines().collect();
13+ let mut want: Vec<&str> = expected.to_vec();
14+ got.sort_unstable();
15+ want.sort_unstable();
16+ assert_eq!(got, want, "stdout line-set mismatch for {name}");
17+}
718
819#[test]
920fn persisted_relation_groupby() {
@@ -124,253 +135,77 @@ fn compile_err_on_invalid_source() {
124135 );
125136}
126137
127−// ── base.compile with a refined expected type (Option B) ───────────────────
138+// ── base.compile rejects a refined expected type ────────────────────────────
128139//
129−// The JIT's expected-type subsumption knows refined types only by NAME — the
130−// predicate never crosses the wire — so the host validates the returned value
131−// against its OWN predicate chain, swapping `Ok` → refinement `Err` on
132−// violation. A snippet must still produce a refined-typed value (the JIT
133−// rejects a bare base value); the host predicate is the authoritative gate on
134−// whatever refined value it produced.
135−
136−#[test]
137−fn compile_refined_accepts_valid() {
138− // The snippet redeclares `Nat` and yields a value that satisfies it via
139− // `refine`; the host's identical predicate accepts it.
140− assert_stdout(
141− "compile_ref_ok",
142− r#"with {
143−Nat Int 1 \x -> x >= 0
144−}
145−(? (base.the (Result Text Nat) (base.compile "with {\nNat Int 1 \\x -> x >= 0\n}\n(? (refine (base.the (Int 1) 5))\n Result.Ok {value n} n\n Result.Err {error e} (0 - 1))"))
146− Result.Ok {value v} base.println ("ok: " ++ base.show v)
147− Result.Err {error e} base.println ("err: " ++ base.show e))"#,
148− "\"ok: 5\"\n{}",
149− );
150−}
151−
152−#[test]
153−fn compile_refined_rejects_invalid_value() {
154− // The snippet redeclares `Nat` with a WEAKER (always-true) predicate so
155− // its own `refine` accepts a negative — but the host's real predicate
156− // (`x >= 0`) must reject the returned value.
157− assert_stdout(
158− "compile_ref_bad",
159− r#"with {
160−Nat Int 1 \x -> x >= 0
161−}
162−(? (base.the (Result Text Nat) (base.compile "with {\nNat Int 1 \\x -> x == x\n}\n(? (refine (base.the (Int 1) (0 - 3)))\n Result.Ok {value n} n\n Result.Err {error e} 0)"))
163− Result.Ok {value v} base.println ("ok: " ++ base.show v)
164− Result.Err {error e} base.println ("err: " ++ base.show e))"#,
165− "\"err: {typeName Nat violations [{field Nothing message value does not satisfy 'Nat' predicate}]}\"\n{}",
166− );
167−}
168−
169−#[test]
170−fn compile_refined_rejects_bare_base_value() {
171− // The snippet does NOT redeclare the refined type and yields a plain
172− // `Int 1` where a refined `Nat` is wanted — the JIT's directional
173− // subsumption rejects the introduction before the host check ever runs,
174− // surfacing as a plain compile `Err`.
175− assert_stdout(
176− "compile_ref_base",
177− r#"with {
178−Nat Int 1 \x -> x >= 0
179−}
180−(? (base.the (Result Text Nat) (base.compile "base.the (Int 1) 5"))
181− Result.Ok {value v} base.println ("ok: " ++ base.show v)
182− Result.Err {error e} base.println "err")"#,
183− "\"err\"\n{}",
184− );
185−}
186−
187−#[test]
188−fn compile_refined_maybe_nothing_skips_check() {
189− // A `Maybe Nat` result of `Nothing` carries no refined scalar — the host
190− // predicate check is skipped and the value passes through.
191− assert_stdout(
192− "compile_ref_maybe_nothing",
193− r#"with {
194−Nat Int 1 \x -> x >= 0
195−}
196−(? (base.the (Result Text (Maybe Nat)) (base.compile "with {\nNat Int 1 \\x -> x >= 0\n}\n(base.the (Maybe Nat) (Maybe.Nothing {}))"))
197− Result.Ok {value v} base.println ("ok: " ++ base.show v)
198− Result.Err {error e} base.println ("err: " ++ base.show e))"#,
199− "\"ok: Nothing\"\n{}",
200− );
201−}
202−
203−#[test]
204−fn compile_refined_maybe_just_checked() {
205− // A `Maybe Nat` result of `Just <bad>` (weakened snippet predicate) IS
206− // unwrapped and checked against the host's real predicate.
207− assert_stdout(
208− "compile_ref_maybe_just",
209− r#"with {
210−Nat Int 1 \x -> x >= 0
211−}
212−(? (base.the (Result Text (Maybe Nat)) (base.compile "with {\nNat Int 1 \\x -> x == x\n}\n(? (refine (base.the (Int 1) (0 - 3)))\n Result.Ok {value n} (base.the (Maybe Nat) (Maybe.Just {value n}))\n Result.Err {error e} (Maybe.Nothing {}))"))
213− Result.Ok {value v} base.println ("ok: " ++ base.show v)
214− Result.Err {error e} base.println ("err: " ++ base.show e))"#,
215− "\"err: {typeName Nat violations [{field Nothing message value does not satisfy 'Nat' predicate}]}\"\n{}",
216− );
217−}
218−
219−#[test]
220−fn compile_refined_stacked_predicates() {
221− // A refinement of a refinement conjoins BOTH host predicates: the snippet
222− // redeclares `Nat` and `Port` with always-true predicates so its own
223− // `refine` yields `-1` (a `Port`) — but the host's chain (`x >= 0` from
224− // `Nat`, `x <= 65535` from `Port`) must reject the negative value.
225− assert_stdout(
226− "compile_ref_stacked",
227− r#"with {
228−Nat Int 1 \x -> x >= 0
229−Port Nat \x -> x <= 65535
230−}
231−(? (base.the (Result Text Port) (base.compile "with {\nNat Int 1 \\x -> x == x\nPort Nat \\x -> x == x\n}\n(? (refine (base.the (Int 1) (0 - 1)))\n Result.Ok {value n} n\n Result.Err {error e} 0)"))
232− Result.Ok {value v} base.println ("ok: " ++ base.show v)
233− Result.Err {error e} base.println "err")"#,
234− "\"err\"\n{}",
235− );
236−}
237−
238−// ── Nested refined expected types (Option B, nested) ───────────────────────
239−//
240−// The host gate now follows the expected type's STRUCTURE: a refined type
241−// nested in a record field, a relation element, or under a `Maybe` is located
242−// by a validation path, and the runtime value is walked down that path to the
243−// refined scalar, which is checked against the host's own predicate chain.
244−
245−#[test]
246−fn compile_refined_record_field_rejects_invalid() {
247− // A refined type nested in a RECORD field: the snippet weakens `Nat` and
248− // returns a bad `port`; the host must reject the whole record. (A
249− // multi-field record type is declared by name — inline multi-field record
250− // annotations need newline-separated fields, which the snippet string
251− // can't carry.)
252− assert_stdout(
253− "compile_ref_rec_bad",
254− r#"with {
255−Nat Int 1 \x -> x >= 0
256−Srv {host Text port Nat}
257−}
258−(? (base.the (Result Text Srv) (base.compile "with {\nNat Int 1 \\x -> x == x\n}\n(? (refine (base.the (Int 1) (0 - 1)))\n Result.Ok {value n} {host \"a\" port n}\n Result.Err {error e} {host \"a\" port 0})"))
259− Result.Ok {value v} base.println ("ok: " ++ base.show v.port)
260− Result.Err {error e} base.println "err")"#,
261− "\"err\"\n{}",
262− );
263−}
264−
265−#[test]
266−fn compile_refined_record_field_accepts_valid() {
267− // A good nested record value passes the host check.
268− assert_stdout(
269− "compile_ref_rec_ok",
270− r#"with {
271−Nat Int 1 \x -> x >= 0
272−Srv {host Text port Nat}
273−}
274−(? (base.the (Result Text Srv) (base.compile "with {\nNat Int 1 \\x -> x == x\n}\n(? (refine (base.the (Int 1) 8080))\n Result.Ok {value n} {host \"a\" port n}\n Result.Err {error e} {host \"a\" port 0})"))
275− Result.Ok {value v} base.println ("ok: " ++ base.show v.port)
276− Result.Err {error e} base.println "err")"#,
277− "\"ok: 8080\"\n{}",
140+// The expected type crosses to the JIT as a NAME-only wire string — the host's
141+// refinement predicates never do — so a refined expected type could only be
142+// checked by name, which a snippet redeclaring `type Nat = Int 1 where
143+// \_ -> True` would slip past. There is no runtime refinement gate on the
144+// result, so `base.compile` REJECTS any host-refined occurrence in its
145+// expected type at compile time (top-level or nested).
146+
147+/// Assert `src` fails to build and its diagnostic mentions `needle`.
148+fn assert_compile_refined_rejected(name: &str, src: &str) {
149+ let dir = e2e::TempDir::fresh(name);
150+ let src_path = dir.join(format!("{name}.knot"));
151+ std::fs::write(&src_path, src).unwrap();
152+ let build = std::process::Command::new(e2e::knot_bin())
153+ .arg("build")
154+ .arg(&src_path)
155+ .arg("-o")
156+ .arg(dir.join(name))
157+ .output()
158+ .expect("failed to run knot build");
159+ assert!(
160+ !build.status.success(),
161+ "expected {name} to fail to build, but it succeeded"
278162 );
279−}
280−
281−#[test]
282−fn compile_refined_record_field_extra_fields_ok() {
283− // Structural subsumption lets the snippet's record carry MORE fields than
284− // the expected type; the host must still find and check `port` without
285− // crashing on the extra field.
286− assert_stdout(
287− "compile_ref_rec_extra",
288− r#"with {
289−Nat Int 1 \x -> x >= 0
290−}
291−(? (base.the (Result Text {port Nat}) (base.compile "with {\nNat Int 1 \\x -> x == x\n}\n(? (refine (base.the (Int 1) (0 - 5)))\n Result.Ok {value n} {extra 9 port n}\n Result.Err {error e} {extra 0 port 0})"))
292− Result.Ok {value v} base.println ("ok: " ++ base.show v.port)
293− Result.Err {error e} base.println "err")"#,
294− "\"err\"\n{}",
163+ let stderr = String::from_utf8_lossy(&build.stderr);
164+ assert!(
165+ stderr.contains("base.compile cannot use the refined type"),
166+ "expected a refined-type rejection for {name}, got:\n{stderr}"
295167 );
296168}
297169
298170#[test]
299−fn compile_refined_relation_rejects_bad_element() {
300− // A refined type as a RELATION element: every element is checked; one bad
301− // element fails the whole relation.
302− assert_stdout(
303− "compile_ref_rel_bad",
171+fn compile_refined_expected_type_rejected() {
172+ // A top-level refined expected type is a compile error, not a Nothing.
173+ assert_compile_refined_rejected(
174+ "compile_ref_rej",
304175 r#"with {
305176Nat Int 1 \x -> x >= 0
306177}
307−(? (base.the (Result Text (Rel Nat)) (base.compile "with {\nNat Int 1 \\x -> x == x\n}\n(base.the (Rel Nat) [1, 2, (0 - 3)])"))
178+(? (base.the (Result Text Nat) (base.compile "1"))
308179 Result.Ok {value v} base.println "ok"
309180 Result.Err {error e} base.println "err")"#,
310− "\"err\"\n{}",
311181 );
312182}
313183
314184#[test]
315−fn compile_refined_relation_accepts_valid() {
316− // A relation whose elements all satisfy the predicate passes.
317− assert_stdout(
318− "compile_ref_rel_ok",
185+fn compile_refined_nested_expected_type_rejected() {
186+ // A refined type nested in the expected type (record field, relation,
187+ // Maybe) is likewise rejected.
188+ assert_compile_refined_rejected(
189+ "compile_ref_nested_rej",
319190 r#"with {
320191Nat Int 1 \x -> x >= 0
321192}
322−(? (base.the (Result Text (Rel Nat)) (base.compile "with {\nNat Int 1 \\x -> x == x\n}\n(base.the (Rel Nat) [1 2 3])"))
193+(? (base.the (Result Text {port Nat}) (base.compile "1"))
323194 Result.Ok {value v} base.println "ok"
324195 Result.Err {error e} base.println "err")"#,
325− "\"ok\"\n{}",
326196 );
327197}
328198
329199#[test]
330−fn compile_refined_relation_of_records() {
331− // Refined scalar nested one level deeper: a relation of records each
332− // carrying a refined field — the path loops the relation, extracts the
333− // field, and checks each.
200+fn compile_unrefined_expected_type_still_works() {
201+ // A non-refined expected type is unaffected: the snippet type-checks and
202+ // subsumes, yielding its value.
334203 assert_stdout(
335− "compile_ref_rel_rec",
336− r#"with {
337−Nat Int 1 \x -> x >= 0
338−}
339−(? (base.the (Result Text (Rel {n Nat})) (base.compile "with {\nNat Int 1 \\x -> x == x\n}\n(base.the (Rel {n Nat}) [{n 1}, {n (0 - 2)}])"))
340− Result.Ok {value v} base.println "ok"
204+ "compile_unrefined_ok",
205+ r#"(? (base.the (Result Text (Int 1)) (base.compile "1 + 2"))
206+ Result.Ok {value v} base.println (base.show v)
341207 Result.Err {error e} base.println "err")"#,
342− "\"err\"\n{}",
343− );
344−}
345−
346−#[test]
347−fn compile_refined_maybe_record_nothing_skips() {
348− // `Maybe {port Nat}` = `Nothing`: no refined scalar anywhere, skip.
349− assert_stdout(
350− "compile_ref_maybe_rec_nothing",
351− r#"with {
352−Nat Int 1 \x -> x >= 0
353−}
354−(? (base.the (Result Text (Maybe {port Nat})) (base.compile "with {\nNat Int 1 \\x -> x == x\n}\n(base.the (Maybe {port Nat}) (Maybe.Nothing {}))"))
355− Result.Ok {value v} base.println ("ok: " ++ base.show v)
356− Result.Err {error e} base.println "err")"#,
357− "\"ok: Nothing\"\n{}",
358− );
359−}
360−
361−#[test]
362−fn compile_refined_maybe_record_just_checked() {
363− // `Maybe {port Nat}` = `Just <record with bad port>`: unwrap the Just,
364− // descend to `port`, and check it against the host predicate.
365− assert_stdout(
366− "compile_ref_maybe_rec_just",
367− r#"with {
368−Nat Int 1 \x -> x >= 0
369−}
370−(? (base.the (Result Text (Maybe {port Nat})) (base.compile "with {\nNat Int 1 \\x -> x == x\n}\n(? (refine (base.the (Int 1) (0 - 4)))\n Result.Ok {value n} (base.the (Maybe {port Nat}) (Maybe.Just {value {port n}}))\n Result.Err {error e} (Maybe.Nothing {}))"))
371− Result.Ok {value v} base.println ("ok: " ++ base.show v)
372− Result.Err {error e} base.println "err")"#,
373− "\"err\"\n{}",
208+ "\"3\"\n{}",
374209 );
375210}
376211
@@ -884,4 +719,250 @@ Rel E *emps
884719 // knot) aborts the same way. That is the existing, general JIT-panic
885720 // behavior, not something this fix changes; the fix is that the *message*
886721 // is the clean overflow report, not the raw SQLite error.
887−}
722+}
723+// ── base.compile results are transactional inside `atomic` ─────────────────
724+//
725+// A compiled snippet used to open its OWN SQLite connection to the host's db
726+// file, so its writes were invisible to — and unrolled-back by — the host's
727+// `atomic` SAVEPOINT. `base.compile` now runs the snippet on the caller's live
728+// db handle (the runtime's CURRENT_DB, set at every knot_value_call/knot_io_run
729+// boundary), so a compiled value inside an `atomic` block shares the host's
730+// transaction: it reads the atomic's uncommitted writes, and its own writes
731+// roll back if the atomic fails.
732+
733+#[test]
734+fn compile_closure_in_atomic_sees_uncommitted_writes() {
735+ // The compiled closure reads *accounts at CALL time (a `\_ ->` function),
736+ // so invoking it inside the atomic — after the atomic's own write — must
737+ // see all 3 rows, not the 1 committed before the atomic began.
738+ assert_stdout(
739+ "compile_atomic_read",
740+ r#"with {
741+Account {name Text balance (Int 1)}
742+Rel Account *accounts
743+}
744+(|
745+ *accounts = [{name "a" balance 1}]
746+ (? (base.compile "with {\nAccount {name Text balance (Int 1)}\nRel Account *accounts\n}\n(\\_ -> base.count *accounts)")
747+ Result.Ok {value countNow} (|
748+ _ <- atomic |
749+ *accounts = [{name "a" balance 1} {name "b" balance 2} {name "c" balance 3}]
750+ base.println (base.show (countNow 0))
751+ yield {}
752+ yield {})
753+ Result.Err {error e} base.println ("compile err: " ++ e))
754+ yield {})"#,
755+ // Sees the atomic's uncommitted write: 3 rows, not 1.
756+ "\"[3]\"\n{}",
757+ );
758+}
759+
760+#[test]
761+fn compile_io_write_in_atomic_rolls_back_on_panic() {
762+ // The compiled IO writes a row inside the atomic; the body then panics
763+ // (integer overflow) so the atomic rolls back. If the snippet shared the
764+ // host's connection, its write is undone and the final count stays [1].
765+ // (On the old separate-connection behavior the row would persist.)
766+ assert_stdout_unordered(
767+ "compile_atomic_rollback",
768+ r#"with {
769+Account {name Text balance (Int 1)}
770+Rel Account *accounts
771+}
772+(|
773+ *accounts = [{name "a" balance 1}]
774+ (? (base.compile "with {\nAccount {name Text balance (Int 1)}\nRel Account *accounts\n}\n(| *accounts = [{name \"sneak\" balance 999}] yield 7)")
775+ Result.Ok {value io} (|
776+ base.fork (atomic |
777+ r <- io
778+ base.println (base.show (9223372036854775807 + 1))
779+ yield {})
780+ base.sleep 300
781+ yield {})
782+ Result.Err {error e} base.println ("compile err: " ++ e))
783+ base.println ("final: " ++ (base.show (base.count *accounts)))
784+ yield {})"#,
785+ &["\"final: [1]\"", "{}"],
786+ );
787+}
788+
789+#[test]
790+fn compile_outside_atomic_still_works() {
791+ // The shared-connection change must not break the ordinary path: a
792+ // compiled pure value used outside any atomic behaves exactly as before.
793+ assert_stdout(
794+ "compile_plain",
795+ r#"(? (base.the (Result Text (Int 1)) (base.compile "6 * 7"))
796+ Result.Ok {value v} base.println (base.show v)
797+ Result.Err {error e} base.println ("err: " ++ e))"#,
798+ "\"42\"\n{}",
799+ );
800+}
801+
802+/// A compiled reader gated by `base.retry`: the snippet reads `*flag` on the
803+/// host's shared connection, so its read registers with the STM change-tracker
804+/// and the atomic is woken when the forked writer flips the flag. On the old
805+/// separate-connection behavior the snippet's read was invisible to the host's
806+/// STM, so the retry would not be driven by the snippet's read. The compiled
807+/// closure runs fresh on each retry iteration and eventually sees the flag.
808+#[test]
809+fn compile_reader_in_atomic_wakes_on_retry() {
810+ assert_stdout(
811+ "compile_atomic_retry",
812+ r#"with {
813+Flag {ready (Int 1)}
814+Rel Flag *flag
815+}
816+(|
817+ *flag = [{ready 0}]
818+ base.fork (|
819+ base.sleep 200
820+ *flag = [{ready 1}]
821+ yield {})
822+ (? (base.compile "with {\nFlag {ready (Int 1)}\nRel Flag *flag\n}\n(\\_ -> base.count *flag)")
823+ Result.Ok {value countNow} (|
824+ got <- atomic |
825+ (? (base.count (countNow 0))
826+ [1] (yield "set")
827+ _ base.retry)
828+ base.println ("compiled reader: " ++ (base.show got))
829+ yield {})
830+ Result.Err {error e} base.println ("compile err: " ++ e))
831+ yield {})"#,
832+ // Blocked in retry until the fork set the flag; the compiled read then
833+ // saw it and the atomic committed.
834+ "\"compiled reader: set\"\n{}",
835+ );
836+}
837+
838+// ── base.compile validates the snippet's declared relations against the host ─
839+//
840+// A snippet may only use relations the host program has, with a matching
841+// shape. The JIT checks each declared relation (name AND structural schema)
842+// against the host's registered schemas via `knot_compile_host_rel_matches`
843+// BEFORE running the snippet — so a foreign or shape-drifted declaration is a
844+// clean compile error, not a SQL panic when the snippet touches the table.
845+
846+#[test]
847+fn compile_rejects_foreign_relation() {
848+ // The host has `*accounts`; the snippet declares `*widgets` (absent in the
849+ // host). Compile must fail cleanly — previously the snippet ran and panicked
850+ // at `SELECT COUNT(*) FROM _knot_widgets` (no such table).
851+ assert_stdout(
852+ "compile_foreign_rel",
853+ r#"with {
854+Account {name Text balance (Int 1)}
855+Rel Account *accounts
856+}
857+(|
858+ *accounts = [{name "a" balance 1}]
859+ (? (base.compile "with {\nWidget {size (Int 1)}\nRel Widget *widgets\n}\nbase.count *widgets")
860+ Result.Ok {value n} base.println ("ok: " ++ (base.show n))
861+ Result.Err {error e} base.println "err")
862+ yield {})"#,
863+ "\"err\"\n{}",
864+ );
865+}
866+
867+#[test]
868+fn compile_rejects_shape_drifted_relation() {
869+ // The host's `Account` is `{name Text balance Int}`; the snippet declares
870+ // `*accounts` as `{balance Int}` (no `name`). Same relation name, different
871+ // shape — must be rejected rather than silently decoding host rows into the
872+ // wrong record type.
873+ assert_stdout(
874+ "compile_shape_drift",
875+ r#"with {
876+Account {name Text balance (Int 1)}
877+Rel Account *accounts
878+}
879+(|
880+ *accounts = [{name "a" balance 1}]
881+ (? (base.compile "with {\nAccount {balance (Int 1)}\nRel Account *accounts\n}\nbase.count *accounts")
882+ Result.Ok {value n} base.println ("ok: " ++ (base.show n))
883+ Result.Err {error e} base.println "err")
884+ yield {})"#,
885+ "\"err\"\n{}",
886+ );
887+}
888+
889+#[test]
890+fn compile_accepts_matching_relation_shape() {
891+ // Same name AND same shape → compiles and reads the host's rows.
892+ assert_stdout(
893+ "compile_matching_rel",
894+ r#"with {
895+Account {name Text balance (Int 1)}
896+Rel Account *accounts
897+}
898+(|
899+ *accounts = [{name "a" balance 1} {name "b" balance 2}]
900+ (? (base.compile "with {\nAccount {name Text balance (Int 1)}\nRel Account *accounts\n}\nbase.count *accounts")
901+ Result.Ok {value n} base.println ("ok: " ++ (base.show n))
902+ Result.Err {error e} base.println ("err: " ++ e))
903+ yield {})"#,
904+ "\"ok: [2]\"\n{}",
905+ );
906+}
907+
908+// ── base.compile ADT constructor variance ─────────────────────────────────
909+//
910+// The expected type may reference a host ADT. The host prepends its `data`
911+// declarations to the wire string so the JIT can compare constructor SETS (not
912+// just names) with the polarity each position demands: a RETURN position is
913+// covariant (snippet ⊆ host — the snippet may produce fewer constructors than
914+// the host is prepared to consume), a PARAMETER is contravariant, and payload
915+// FIELD types are checked at the matching variance. A regression that dropped
916+// the host's data decls (or mis-parsed the `data Name = …` wire form) left the
917+// ctor set empty and made every case vacuously pass — these pin the real
918+// behavior.
919+
920+#[test]
921+fn compile_accepts_return_with_fewer_ctors() {
922+ // Covariant return: the snippet returns only `Red`/`Green` where the host
923+ // expects `{Red, Green, Blue}` — a subset, so it compiles.
924+ assert_stdout(
925+ "compile_ctor_subset",
926+ r#"with {
927+Color Red {} Green {} Blue {}
928+}
929+(? (base.the (Result Text (Int 1 -> Color)) (base.compile "with {\nColor Red {} Green {}\n}\n\\x -> (Color.Red {})"))
930+ Result.Ok {value _} base.println "compiled"
931+ Result.Err {error _} base.println "err")"#,
932+ "\"compiled\"\n{}",
933+ );
934+}
935+
936+#[test]
937+fn compile_rejects_return_with_extra_ctor() {
938+ // Covariant return: the snippet returns `Blue`, which the host's
939+ // `{Red, Green}` cannot consume — a snippet-only ctor, so it is rejected.
940+ assert_stdout(
941+ "compile_ctor_superset",
942+ r#"with {
943+Color Red {} Green {}
944+}
945+(? (base.the (Result Text (Int 1 -> Color)) (base.compile "with {\nColor Red {} Green {} Blue {}\n}\n\\x -> (Color.Blue {})"))
946+ Result.Ok {value _} base.println "compiled"
947+ Result.Err {error _} base.println "err")"#,
948+ "\"err\"\n{}",
949+ );
950+}
951+
952+#[test]
953+fn compile_rejects_ctor_field_type_drift() {
954+ // Same single ctor `W`, but the payload field type drifts (`Text` in the
955+ // snippet, `Int 1` in the host). The consumer (host) binds `n` at `Int 1`,
956+ // so a snippet producing `Text` must be rejected.
957+ assert_stdout(
958+ "compile_ctor_field_drift",
959+ r#"with {
960+Wrap W {n (Int 1)}
961+}
962+(? (base.the (Result Text (Int 1 -> Wrap)) (base.compile "with {\nWrap W {n Text}\n}\n\\x -> (Wrap.W {n \"s\"})"))
963+ Result.Ok {value _} base.println "compiled"
964+ Result.Err {error _} base.println "err")"#,
965+ "\"err\"\n{}",
966+ );
967+}
968+
crates/knot-jit/tests/refinements.rs+155−7
@@ -79,29 +79,61 @@ R {lo (Int 1) hi (Int 1)} \r -> r.lo <= r.hi
7979
8080#[test]
8181fn write_validation_rejects_violation() {
82− // A refined field on a source relation is validated on write; a violation
83− // aborts the write.
82+ // A statically-known value that violates a refined field is rejected at
83+ // COMPILE time — by-construction means the predicate runs on constants
84+ // during the build, so the program never reaches the runtime write-check.
8485 let dir = e2e::TempDir::fresh("refine_write");
86+ let src = r#"with {
87+Nat Int 1 \x -> x >= 0
88+Rel {name Text age Nat} *people
89+}
90+(|
91+ *people = [{name "a" age (0 - 5)}]
92+ base.println "wrote"
93+ yield {})"#;
94+ let src_path = dir.path().join("refine_write.knot");
95+ std::fs::write(&src_path, src).unwrap();
96+ let build = std::process::Command::new(e2e::knot_bin())
97+ .arg("build")
98+ .arg(&src_path)
99+ .arg("-o")
100+ .arg(dir.path().join("refine_write"))
101+ .output()
102+ .expect("run knot build");
103+ let stderr = String::from_utf8_lossy(&build.stderr);
104+ assert!(
105+ !build.status.success() && stderr.contains("does not satisfy"),
106+ "expected a compile-time refinement rejection, got status={} stderr={}",
107+ build.status,
108+ stderr
109+ );
110+}
111+
112+#[test]
113+fn write_validation_runtime_catches_nonconstant() {
114+ // A value the compiler cannot statically evaluate (a lambda parameter)
115+ // still defers to the runtime write-check, which aborts the write.
116+ let dir = e2e::TempDir::fresh("refine_write_rt");
85117 e2e::build_in_dir(
86− "refine_write",
118+ "refine_write_rt",
87119 r#"with {
88120Nat Int 1 \x -> x >= 0
89121Rel {name Text age Nat} *people
90122}
91123(|
92− *people = [{name "a" age (0 - 5)}]
124+ _ <- (base.traverse (\a -> (*people = [{name "a" age a}])) [(0 - 5)])
93125 base.println "wrote"
94126 yield {})"#,
95127 dir.path(),
96128 );
97− let out = std::process::Command::new(dir.path().join("refine_write"))
129+ let out = std::process::Command::new(dir.path().join("refine_write_rt"))
98130 .current_dir(dir.path())
99131 .output()
100132 .expect("run");
101133 let stderr = String::from_utf8_lossy(&out.stderr);
102134 assert!(
103135 !out.status.success() || stderr.contains("refin") || stderr.contains("violation"),
104− "expected write-validation rejection, got stdout={} stderr={}",
136+ "expected runtime write-validation rejection, got stdout={} stderr={}",
105137 String::from_utf8_lossy(&out.stdout),
106138 stderr
107139 );
@@ -122,4 +154,120 @@ Rel {name Text age Nat} *people
122154 // count is a one-element relation now (Rel Int), so it shows as [1].
123155 "\"[1]\"\n{}",
124156 );
125−}
157+}
158+
159+/// Build `src`, returning `(status_ok, stderr)` — for asserting a compile-time
160+/// rejection without running the binary.
161+fn build_outcome(name: &str, src: &str) -> (bool, String) {
162+ let dir = e2e::TempDir::fresh(name);
163+ let src_path = dir.path().join(format!("{name}.knot"));
164+ std::fs::write(&src_path, src).unwrap();
165+ let build = std::process::Command::new(e2e::knot_bin())
166+ .arg("build")
167+ .arg(&src_path)
168+ .arg("-o")
169+ .arg(dir.path().join(name))
170+ .output()
171+ .expect("run knot build");
172+ (build.status.success(), String::from_utf8_lossy(&build.stderr).into_owned())
173+}
174+
175+#[test]
176+fn write_validation_rejects_whole_record_refined_alias() {
177+ // A constant row violating a whole-record refined alias (`VP`) written to
178+ // `Rel VP` is rejected at COMPILE time, not deferred to the runtime
179+ // write-check.
180+ let (ok, stderr) = build_outcome(
181+ "refine_whole_rec",
182+ r#"with {
183+VP {age (Int 1 \x -> x >= 0)}
184+Rel VP *people
185+}
186+(|
187+ *people = [{age (0 - 5)}]
188+ base.println "wrote"
189+ yield {})"#,
190+ );
191+ assert!(
192+ !ok && stderr.contains("does not satisfy"),
193+ "expected compile-time rejection of whole-record refined write, got ok={ok} stderr={stderr}"
194+ );
195+}
196+
197+#[test]
198+fn write_validation_accepts_whole_record_refined_alias_valid() {
199+ assert_stdout(
200+ "refine_whole_rec_ok",
201+ r#"with {
202+VP {age (Int 1 \x -> x >= 0)}
203+Rel VP *people
204+}
205+(|
206+ *people = [{age 30}]
207+ base.println (base.show (base.map (\p -> p.age) *people))
208+ yield {})"#,
209+ "\"[30]\"\n{}",
210+ );
211+}
212+
213+#[test]
214+fn refined_return_rejects_violating_constant() {
215+ // A function whose declared return type is a refined scalar, returning a
216+ // violating constant, is a compile error — by-construction, no silent
217+ // laundering into the refined type.
218+ let (ok, stderr) = build_outcome(
219+ "refine_ret_bad",
220+ r#"with {
221+Nat Int 1 \x -> x >= 0
222+Nat -> Nat f (\x -> (0 - 5))
223+}
224+base.println "done""#,
225+ );
226+ assert!(
227+ !ok && stderr.contains("does not satisfy"),
228+ "expected compile-time rejection of refined return, got ok={ok} stderr={stderr}"
229+ );
230+}
231+
232+#[test]
233+fn refined_return_accepts_valid_constant() {
234+ assert_stdout(
235+ "refine_ret_ok",
236+ r#"with {
237+Nat Int 1 \x -> x >= 0
238+Nat -> Nat f (\x -> (5 - 0))
239+}
240+base.println (base.show (f 0))"#,
241+ "\"5\"\n{}",
242+ );
243+}
244+
245+#[test]
246+fn the_ascription_rejects_violating_constant() {
247+ // `base.the (Nat) (0-5)`: the type-witness path checks the value against
248+ // the ascribed refined type. A violating constant is a compile error, not
249+ // a silent launder into `Nat` (the witness never touches a source write).
250+ let (ok, stderr) = build_outcome(
251+ "refine_the_bad",
252+ r#"with {
253+Nat Int 1 \x -> x >= 0
254+}
255+base.println (base.show (base.the (Nat) (0 - 5)))"#,
256+ );
257+ assert!(
258+ !ok && stderr.contains("does not satisfy"),
259+ "expected compile-time rejection of `base.the` refined ascription, got ok={ok} stderr={stderr}"
260+ );
261+}
262+
263+#[test]
264+fn the_ascription_accepts_valid_constant() {
265+ assert_stdout(
266+ "refine_the_ok",
267+ r#"with {
268+Nat Int 1 \x -> x >= 0
269+}
270+base.println (base.show (base.the (Nat) 5))"#,
271+ "\"5\"\n{}",
272+ );
273+}
crates/knot-lsp/src/defs.rs+4−1
@@ -352,7 +352,10 @@ impl<'a> DefResolver<'a> {
352352 }
353353 TypeKind::IO { ty, .. } => self.resolve_type(ty, source),
354354 TypeKind::Unit(_) => {}
355− TypeKind::UnitAnnotated { base, .. } => self.resolve_type(base, source),
355+ TypeKind::UnitMul(a, b) | TypeKind::UnitDiv(a, b) => {
356+ self.resolve_type(a, source);
357+ self.resolve_type(b, source);
358+ }
356359 TypeKind::Refined { base, predicate } => {
357360 self.resolve_type(base, source);
358361 self.resolve_expr(predicate);
crates/knot-lsp/src/incremental.rs+4−1
@@ -506,7 +506,10 @@ fn collect_type_names(ty: &ast::Type, out: &mut HashSet<String>) {
506506 }
507507 ast::TypeKind::IO { ty, .. } => collect_type_names(ty, out),
508508 ast::TypeKind::Unit(_) => {}
509− ast::TypeKind::UnitAnnotated { base, .. } => collect_type_names(base, out),
509+ ast::TypeKind::UnitMul(a, b) | ast::TypeKind::UnitDiv(a, b) => {
510+ collect_type_names(a, out);
511+ collect_type_names(b, out);
512+ }
510513 ast::TypeKind::Refined { base, .. } => collect_type_names(base, out),
511514 ast::TypeKind::Forall { ty, .. } => collect_type_names(ty, out),
512515 }
crates/knot-lsp/src/linked_editing.rs+4−2
@@ -250,8 +250,10 @@ fn collect_type_field_name_spans(
250250 ast::TypeKind::IO { ty: inner, .. } => {
251251 collect_type_field_name_spans(inner, field_name, source, ranges)
252252 }
253− ast::TypeKind::UnitAnnotated { base, .. } => {
254− collect_type_field_name_spans(base, field_name, source, ranges)
253+ ast::TypeKind::Unit(_) => {}
254+ ast::TypeKind::UnitMul(a, b) | ast::TypeKind::UnitDiv(a, b) => {
255+ collect_type_field_name_spans(a, field_name, source, ranges);
256+ collect_type_field_name_spans(b, field_name, source, ranges);
255257 }
256258 ast::TypeKind::Refined { base, .. } => {
257259 collect_type_field_name_spans(base, field_name, source, ranges)
crates/knot-lsp/src/rename.rs+5−1
@@ -1078,7 +1078,11 @@ fn field_sites_in_type<F: FnMut(&str, Span)>(ty: &ast::Type, source: &str, f: &m
10781078 field_sites_in_type(result, source, f);
10791079 }
10801080 ast::TypeKind::IO { ty: inner, .. } => field_sites_in_type(inner, source, f),
1081− ast::TypeKind::UnitAnnotated { base, .. } => field_sites_in_type(base, source, f),
1081+ ast::TypeKind::Unit(_) => {}
1082+ ast::TypeKind::UnitMul(a, b) | ast::TypeKind::UnitDiv(a, b) => {
1083+ field_sites_in_type(a, source, f);
1084+ field_sites_in_type(b, source, f);
1085+ }
10821086 ast::TypeKind::Refined { base, .. } => field_sites_in_type(base, source, f),
10831087 ast::TypeKind::Forall { ty: inner, .. } => field_sites_in_type(inner, source, f),
10841088 _ => {}
crates/knot-lsp/src/semantic_tokens.rs+3−2
@@ -656,8 +656,9 @@ impl<'a> TokenCollector<'a> {
656656 self.visit_type(inner);
657657 }
658658 ast::TypeKind::Unit(_) => {}
659− ast::TypeKind::UnitAnnotated { base, .. } => {
660− self.visit_type(base);
659+ ast::TypeKind::UnitMul(a, b) | ast::TypeKind::UnitDiv(a, b) => {
660+ self.visit_type(a);
661+ self.visit_type(b);
661662 }
662663 // Leaves with no named sub-types. Listed explicitly (rather than a
663664 // `_` wildcard) so a newly added `TypeKind` variant surfaces as a
crates/knot-lsp/src/shared.rs+7−2
@@ -1051,7 +1051,10 @@ fn type_contains_offset(ty: &ast::Type, offset: usize) -> bool {
10511051 .any(|f| type_contains_offset(&f.value, offset))
10521052 }),
10531053 ast::TypeKind::IO { ty, .. } => type_contains_offset(ty, offset),
1054− ast::TypeKind::UnitAnnotated { base, .. } => type_contains_offset(base, offset),
1054+ ast::TypeKind::Unit(_) => false,
1055+ ast::TypeKind::UnitMul(a, b) | ast::TypeKind::UnitDiv(a, b) => {
1056+ type_contains_offset(a, offset) || type_contains_offset(b, offset)
1057+ }
10551058 ast::TypeKind::Refined { base, .. } => type_contains_offset(base, offset),
10561059 ast::TypeKind::Forall { ty, .. } => type_contains_offset(ty, offset),
10571060 _ => false,
@@ -1125,9 +1128,11 @@ fn type_mentions_var(ty: &ast::Type, var: &str) -> bool {
11251128 .iter()
11261129 .any(|c| c.fields.iter().any(|f| type_mentions_var(&f.value, var))),
11271130 ast::TypeKind::IO { ty, .. }
1128− | ast::TypeKind::UnitAnnotated { base: ty, .. }
11291131 | ast::TypeKind::Refined { base: ty, .. } => type_mentions_var(ty, var),
11301132 ast::TypeKind::Unit(_) => false,
1133+ ast::TypeKind::UnitMul(a, b) | ast::TypeKind::UnitDiv(a, b) => {
1134+ type_mentions_var(a, var) || type_mentions_var(b, var)
1135+ }
11311136 ast::TypeKind::Forall { vars, ty } => {
11321137 !vars.iter().any(|n| n == var) && type_mentions_var(ty, var)
11331138 }
crates/knot-lsp/src/type_format.rs+8−10
@@ -69,7 +69,8 @@ fn format_type_kind_d(ty: &TypeKind, depth: usize) -> String {
6969 | TypeKind::IO { .. }
7070 | TypeKind::Refined { .. }
7171 | TypeKind::Forall { .. }
72− | TypeKind::Unit(_)
72+ | TypeKind::UnitMul(..)
73+ | TypeKind::UnitDiv(..)
7374 ) {
7475 format!("({a})")
7576 } else {
@@ -133,16 +134,13 @@ fn format_type_kind_d(ty: &TypeKind, depth: usize) -> String {
133134 }
134135 TypeKind::Hole => "_".into(),
135136 TypeKind::Callsite => "?".into(),
136− TypeKind::UnitAnnotated { base, unit } => {
137− // `Float M`, `Float (M / S^2)` — space-separated, parens for compound.
138− let u = format_unit_expr_d(unit, d);
139− if u.contains(' ') || u.contains('^') {
140− format!("{} ({})", format_type_kind_d(&base.node, d), u)
141− } else {
142− format!("{} {}", format_type_kind_d(&base.node, d), u)
143− }
144− }
145137 TypeKind::Unit(u) => format_unit_expr_d(u, d),
138+ TypeKind::UnitMul(a, b) => {
139+ format!("{} * {}", format_type_kind_d(&a.node, d), format_type_kind_d(&b.node, d))
140+ }
141+ TypeKind::UnitDiv(a, b) => {
142+ format!("{} / {}", format_type_kind_d(&a.node, d), format_type_kind_d(&b.node, d))
143+ }
146144 TypeKind::Refined { base, predicate } => {
147145 format!(
148146 "{} where {}",
crates/knot-runtime/Cargo.toml+1
@@ -14,6 +14,7 @@ default = []
1414gc-stats = []
1515
1616[dependencies]
17+knot-descriptor = { workspace = true }
1718rusqlite = { workspace = true }
1819tiny_http = { workspace = true }
1920ratatui = "0.30"
crates/knot-runtime/src/db_dump.rsnew file+143
@@ -0,0 +1,143 @@
1+//! Binary dump/restore of a knot database as a single portable file.
2+//!
3+//! `dump` uses SQLite's `VACUUM INTO`, which produces a consistent,
4+//! self-contained copy of a *live* database (WAL content is checkpointed into
5+//! the copy; no `-wal`/`-shm` sidecars). The result is itself a valid SQLite
6+//! database — so `restore` is just copying that file over the target, atomically
7+//! (write to a temp sibling, then rename) so an interrupted restore never
8+//! leaves a half-written db.
9+
10+use rusqlite::Connection;
11+use std::path::Path;
12+
13+/// Copy the database at `src` to a single binary file at `dest`, consistent
14+/// even while `src` is live. Safe to call on a database another process is
15+/// writing: `VACUUM INTO` runs under the source's own locking.
16+///
17+/// `dest` must not already exist (SQLite refuses to overwrite), so a stale
18+/// copy is removed first only if it's a file we're replacing wholesale.
19+pub fn dump_db(src: &Path, dest: &Path) -> Result<(), String> {
20+ if !src.exists() {
21+ return Err(format!("no database at {}", src.display()));
22+ }
23+ // SQLite requires the VACUUM INTO target to not exist. Remove a previous
24+ // dump so re-dumping to the same path works.
25+ if dest.exists() {
26+ std::fs::remove_file(dest)
27+ .map_err(|e| format!("cannot replace existing dump at {}: {}", dest.display(), e))?;
28+ }
29+ let conn = Connection::open(src).map_err(|e| format!("cannot open {}: {}", src.display(), e))?;
30+ let dest_str = dest
31+ .to_str()
32+ .ok_or_else(|| format!("destination path is not valid UTF-8: {}", dest.display()))?;
33+ // Escape single quotes for the SQL string literal.
34+ let escaped = dest_str.replace('\'', "''");
35+ conn.execute_batch(&format!("VACUUM INTO '{}';", escaped))
36+ .map_err(|e| format!("dump failed: {}", e))
37+}
38+
39+/// Restore a database: copy a dump file (produced by [`dump_db`]) over `dest`
40+/// atomically. The dump is a complete SQLite database, so restore is a file
41+/// copy — done via a temp sibling + rename so a crash mid-restore leaves the
42+/// old `dest` intact rather than a truncated one.
43+///
44+/// The target must not be in use: this replaces the file, which a live writer
45+/// would not expect. (Dump is live-safe; restore is an offline operation.)
46+pub fn restore_db(dump: &Path, dest: &Path) -> Result<(), String> {
47+ if !dump.exists() {
48+ return Err(format!("no dump file at {}", dump.display()));
49+ }
50+ // Sanity-check the dump is a SQLite db before overwriting anything.
51+ Connection::open(dump)
52+ .and_then(|c| c.query_row("SELECT count(*) FROM sqlite_master", [], |_| Ok(())))
53+ .map_err(|e| format!("{} is not a valid database: {}", dump.display(), e))?;
54+
55+ let parent = dest
56+ .parent()
57+ .ok_or_else(|| format!("destination has no parent directory: {}", dest.display()))?;
58+ let tmp = parent.join(format!(
59+ ".{}.knot-restore-tmp",
60+ dest.file_name()
61+ .and_then(|n| n.to_str())
62+ .unwrap_or("db")
63+ ));
64+ std::fs::copy(dump, &tmp).map_err(|e| format!("failed to stage restore: {}", e))?;
65+ std::fs::rename(&tmp, dest).map_err(|e| {
66+ let _ = std::fs::remove_file(&tmp);
67+ format!("failed to move restored db into place: {}", e)
68+ })
69+}
70+
71+#[cfg(test)]
72+mod tests {
73+ use super::*;
74+
75+ fn fresh(dir: &str, name: &str) -> std::path::PathBuf {
76+ let d = std::env::temp_dir().join(format!("knot_dbdump_{}_{}", dir, std::process::id()));
77+ std::fs::create_dir_all(&d).unwrap();
78+ d.join(name)
79+ }
80+
81+ /// Build a small db with a known row.
82+ fn make_db(path: &Path) {
83+ let c = Connection::open(path).unwrap();
84+ c.execute_batch("CREATE TABLE t (name TEXT, n INTEGER); INSERT INTO t VALUES ('a', 1), ('b', 2);").unwrap();
85+ }
86+
87+ fn row_count(path: &Path) -> i64 {
88+ Connection::open(path)
89+ .unwrap()
90+ .query_row("SELECT count(*) FROM t", [], |r| r.get(0))
91+ .unwrap()
92+ }
93+
94+ #[test]
95+ fn dump_then_restore_roundtrips_data() {
96+ let src = fresh("rt", "src.db");
97+ let dump = fresh("rt", "out.dump");
98+ let restored = fresh("rt", "restored.db");
99+ make_db(&src);
100+ dump_db(&src, &dump).unwrap();
101+ assert!(dump.exists());
102+ restore_db(&dump, &restored).unwrap();
103+ assert_eq!(row_count(&restored), 2);
104+ }
105+
106+ #[test]
107+ fn dump_is_a_valid_single_file_db() {
108+ let src = fresh("valid", "src.db");
109+ let dump = fresh("valid", "out.dump");
110+ make_db(&src);
111+ dump_db(&src, &dump).unwrap();
112+ // The dump opens directly as a database and holds the data.
113+ assert_eq!(row_count(&dump), 2);
114+ // No WAL/SHM sidecars: one self-contained file.
115+ assert!(!dump.with_extension("dump-wal").exists());
116+ assert!(!dump.with_extension("dump-shm").exists());
117+ }
118+
119+ #[test]
120+ fn dump_missing_source_errors() {
121+ let r = dump_db(&fresh("err", "nope.db"), &fresh("err", "out.dump"));
122+ assert!(r.is_err());
123+ }
124+
125+ #[test]
126+ fn restore_rejects_a_non_database() {
127+ let bad = fresh("bad", "bad.dump");
128+ std::fs::write(&bad, b"not a database").unwrap();
129+ let r = restore_db(&bad, &fresh("bad", "out.db"));
130+ assert!(r.is_err());
131+ }
132+
133+ #[test]
134+ fn redump_replaces_existing_dump() {
135+ let src = fresh("redump", "src.db");
136+ let dump = fresh("redump", "out.dump");
137+ make_db(&src);
138+ dump_db(&src, &dump).unwrap();
139+ // Second dump to the same path must succeed (not "target exists").
140+ dump_db(&src, &dump).unwrap();
141+ assert_eq!(row_count(&dump), 2);
142+ }
143+}
crates/knot-runtime/src/lib.rs+1225−210
@@ -10,6 +10,7 @@
1010// without benefit since they are never called from safe Rust.
1111#![allow(clippy::not_unsafe_ptr_arg_deref)]
1212
13+pub mod db_dump;
1314pub mod log;
1415mod tui;
1516
@@ -1077,11 +1078,13 @@ impl Arena {
10771078 Value::Function(f) => {
10781079 let ne = self.promote_value(f.env);
10791080 if ne != f.env {
1081+ unsafe { code_handle_retain(f.code) };
10801082 self.alloc_pinned(Value::Function(Box::new(FunctionInner {
10811083 fn_ptr: f.fn_ptr,
10821084 env: ne,
10831085 source: f.source.clone(),
10841086 extract_source: f.extract_source.clone(),
1087+ code: f.code,
10851088 })))
10861089 } else {
10871090 val
@@ -1090,10 +1093,12 @@ impl Arena {
10901093 Value::IO(io) => {
10911094 let ne = self.promote_value(io.env);
10921095 if ne != io.env {
1096+ unsafe { code_handle_retain(io.code) };
10931097 self.alloc_pinned(Value::IO(Box::new(IOInner {
10941098 fn_ptr: io.fn_ptr,
10951099 env: ne,
10961100 source: io.source.clone(),
1101+ code: io.code,
10971102 })))
10981103 } else {
10991104 val
@@ -1220,17 +1225,25 @@ impl Arena {
12201225 Value::Constructor(tag, inner) => {
12211226 Value::Constructor(tag.clone(), self.promote_value(*inner))
12221227 }
1223− Value::Function(f) => Value::Function(Box::new(FunctionInner {
1224− fn_ptr: f.fn_ptr,
1225− env: self.promote_value(f.env),
1226− source: f.source.clone(),
1227− extract_source: f.extract_source.clone(),
1228− })),
1229− Value::IO(io) => Value::IO(Box::new(IOInner {
1230− fn_ptr: io.fn_ptr,
1231− env: self.promote_value(io.env),
1232− source: io.source.clone(),
1233− })),
1228+ Value::Function(f) => {
1229+ unsafe { code_handle_retain(f.code) };
1230+ Value::Function(Box::new(FunctionInner {
1231+ fn_ptr: f.fn_ptr,
1232+ env: self.promote_value(f.env),
1233+ source: f.source.clone(),
1234+ extract_source: f.extract_source.clone(),
1235+ code: f.code,
1236+ }))
1237+ }
1238+ Value::IO(io) => {
1239+ unsafe { code_handle_retain(io.code) };
1240+ Value::IO(Box::new(IOInner {
1241+ fn_ptr: io.fn_ptr,
1242+ env: self.promote_value(io.env),
1243+ source: io.source.clone(),
1244+ code: io.code,
1245+ }))
1246+ }
12341247 Value::Pair(a, b) => Value::Pair(self.promote_value(*a), self.promote_value(*b)),
12351248 };
12361249 self.alloc_pinned(cloned)
@@ -1304,17 +1317,25 @@ impl Arena {
13041317 Value::Constructor(tag, inner) => {
13051318 Value::Constructor(tag.clone(), self.clone_from_child(*inner, child))
13061319 }
1307− Value::Function(f) => Value::Function(Box::new(FunctionInner {
1308− fn_ptr: f.fn_ptr,
1309− env: self.clone_from_child(f.env, child),
1310− source: f.source.clone(),
1311− extract_source: f.extract_source.clone(),
1312− })),
1313− Value::IO(io) => Value::IO(Box::new(IOInner {
1314− fn_ptr: io.fn_ptr,
1315− env: self.clone_from_child(io.env, child),
1316− source: io.source.clone(),
1317− })),
1320+ Value::Function(f) => {
1321+ unsafe { code_handle_retain(f.code) };
1322+ Value::Function(Box::new(FunctionInner {
1323+ fn_ptr: f.fn_ptr,
1324+ env: self.clone_from_child(f.env, child),
1325+ source: f.source.clone(),
1326+ extract_source: f.extract_source.clone(),
1327+ code: f.code,
1328+ }))
1329+ }
1330+ Value::IO(io) => {
1331+ unsafe { code_handle_retain(io.code) };
1332+ Value::IO(Box::new(IOInner {
1333+ fn_ptr: io.fn_ptr,
1334+ env: self.clone_from_child(io.env, child),
1335+ source: io.source.clone(),
1336+ code: io.code,
1337+ }))
1338+ }
13181339 Value::Pair(a, b) => Value::Pair(
13191340 self.clone_from_child(*a, child),
13201341 self.clone_from_child(*b, child),
@@ -2127,6 +2148,32 @@ fn get_source_schema(name: &str) -> Option<Arc<RecordSchema>> {
21272148 .cloned()
21282149}
21292150
2151+/// `base.compile` host-relation check: does the host have relation `name`, and
2152+/// does its registered schema match the snippet's declared `descriptor`?
2153+/// Returns 0 = match, 1 = shape mismatch, 2 = host has no such relation.
2154+/// Called by the JIT *before* it runs the snippet, so a foreign or
2155+/// shape-drifted relation is rejected as a clean compile error rather than a
2156+/// SQL panic when the snippet's `main` touches the (missing/wrong) table.
2157+#[unsafe(no_mangle)]
2158+pub extern "C-unwind" fn knot_compile_host_rel_matches(
2159+ name_ptr: *const u8,
2160+ name_len: usize,
2161+ desc_ptr: *const u8,
2162+ desc_len: usize,
2163+) -> i32 {
2164+ let name = unsafe { str_from_raw(name_ptr, name_len) };
2165+ let desc = unsafe { str_from_raw(desc_ptr, desc_len) };
2166+ let host_rels = SOURCE_SCHEMAS.read().unwrap_or_else(|e| e.into_inner());
2167+ let Some(host_schema) = host_rels.get(name) else {
2168+ return 2;
2169+ };
2170+ if parse_record_schema(desc) == **host_schema {
2171+ 0
2172+ } else {
2173+ 1
2174+ }
2175+}
2176+
21302177/// Registry of columns stored as genuine TEXT (BINARY collation in SQLite) —
21312178/// `ColType::Text`, `Tag`, or `Json` — keyed by `(source, column)`. Int columns
21322179/// are native INTEGERs, so they never appear here.
@@ -3504,8 +3551,7 @@ fn debug_sql_params(sql: &str, params: &[rusqlite::types::Value]) {
35043551/// scalar overrides only the last occurrence is meaningful (last-wins,
35053552/// matching the previous single-slot behavior). `is_bool` enables the
35063553/// bare-flag-means-true two-arg disambiguation.
3507−fn override_flag_values(name: &str, is_bool: bool) -> Vec<String> {
3508− let flag = format!("--{}", name);
3554+fn override_flag_values(name: &str, is_bool: bool) -> Vec<String> { let flag = format!("--{}", name);
35093555 // Use args_os and skip non-UTF-8 entries: std::env::args() panics on
35103556 // non-UTF-8 argv, which would crash before any override lookup runs.
35113557 let args: Vec<String> = std::env::args_os()
@@ -3564,6 +3610,23 @@ fn override_parse_error(name: &str, val: &str, expected: &str) -> ! {
35643610/// Scans `std::env::args()` for `--{name}=value` or `--{name} value`.
35653611/// `type_tag`: 0=Int, 1=Float, 2=Text, 3=Bool, 4=Maybe Int, 5=Maybe Float, 6=Maybe Text, 7=Maybe Bool.
35663612/// Maybe tags automatically wrap the parsed value in `Just`.
3613+/// Forbid a CLI flag for a `#`-argument whose type isn't CLI-settable (a
3614+/// record, a function, a collection of non-scalars). When such an arg has a
3615+/// default, codegen routes here so a passed flag aborts loudly instead of
3616+/// silently falling through to the default. No-op when the flag is absent.
3617+#[unsafe(no_mangle)]
3618+pub extern "C-unwind" fn knot_override_forbid(name_ptr: *const u8, name_len: usize) -> *mut Value {
3619+ let name = unsafe { str_from_raw(name_ptr, name_len) };
3620+ if !override_flag_values(name, false).is_empty() {
3621+ eprintln!(
3622+ "Error: argument --{} has a type that cannot be set from the command line",
3623+ name
3624+ );
3625+ std::process::exit(1);
3626+ }
3627+ alloc(Value::Unit)
3628+}
3629+
35673630/// Returns a boxed `Value` if found, or null if absent.
35683631/// On parse error, prints a message and exits.
35693632#[unsafe(no_mangle)]
@@ -3641,14 +3704,47 @@ pub extern "C-unwind" fn knot_override_lookup(
36413704 }
36423705}
36433706
3707+/// The element type of a collection override (`Vec`/`Rel`/`List <elem>`).
3708+enum ElemKind<'a> {
3709+ /// A CLI-parseable scalar: "Int" | "Float" | "Text" | "Bool".
3710+ Scalar(&'a str),
3711+ Bytes,
3712+ Uuid,
3713+ /// All-nullary ADT: valid constructor names.
3714+ Enum(&'a [String]),
3715+ /// Refined element: the base scalar it parses as. The per-element
3716+ /// predicate check runs in generated code, not here.
3717+ Refined(&'a str),
3718+}
3719+
3720+impl<'a> ElemKind<'a> {
3721+ fn is_bool(&self) -> bool {
3722+ matches!(self, ElemKind::Scalar("Bool"))
3723+ }
3724+ fn expected(&self) -> String {
3725+ match self {
3726+ ElemKind::Scalar(s) => s.to_string(),
3727+ ElemKind::Bytes => "base64url-encoded Bytes".to_string(),
3728+ ElemKind::Uuid => "a Uuid".to_string(),
3729+ ElemKind::Enum(ctors) => format!("one of: {}", ctors.join(", ")),
3730+ ElemKind::Refined(base) => base.to_string(),
3731+ }
3732+ }
3733+}
3734+
36443735/// The override kinds that go through `knot_override_lookup_ex` rather than
36453736/// the tag-based `knot_override_lookup`. Parsed from the compiler-emitted
36463737/// descriptor string.
36473738enum ExKind<'a> {
36483739 Bytes,
36493740 Uuid,
3650− /// Element scalar for `Rel <scalar>` ("Int" | "Float" | "Text" | "Bool").
3651− Rel(&'a str),
3741+ /// Element type for `Rel <elem>`. A relation is a SET: rows are deduped.
3742+ Rel(ElemKind<'a>),
3743+ /// `Vec <elem>` — ordered, allows duplicates. Shares the relation
3744+ /// representation but skips dedup.
3745+ Vec(ElemKind<'a>),
3746+ /// `List <elem>` — a Cons/Nil chain.
3747+ List(ElemKind<'a>),
36523748 /// All-nullary ADT: valid constructor names.
36533749 Enum(&'a [String]),
36543750 /// Refined scalar: the base scalar ("Int" | "Float" | "Text" | "Bool").
@@ -3659,10 +3755,11 @@ enum ExKind<'a> {
36593755impl<'a> ExKind<'a> {
36603756 /// Whether the flag takes a bare `--name` form (bools only).
36613757 fn is_bool(&self) -> bool {
3662− matches!(
3663− self,
3664− ExKind::Rel("Bool") | ExKind::Refined("Bool")
3665− )
3758+ match self {
3759+ ExKind::Rel(e) | ExKind::Vec(e) | ExKind::List(e) => e.is_bool(),
3760+ ExKind::Refined(b) => *b == "Bool",
3761+ _ => false,
3762+ }
36663763 }
36673764
36683765 /// Expected-type wording for parse errors.
@@ -3670,7 +3767,9 @@ impl<'a> ExKind<'a> {
36703767 match self {
36713768 ExKind::Bytes => "base64url-encoded Bytes".to_string(),
36723769 ExKind::Uuid => "a Uuid (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)".to_string(),
3673− ExKind::Rel(e) => format!("a comma-separated list of {}", e),
3770+ ExKind::Rel(e) | ExKind::Vec(e) | ExKind::List(e) => {
3771+ format!("a comma-separated list of {}", e.expected())
3772+ }
36743773 ExKind::Enum(ctors) => format!("one of: {}", ctors.join(", ")),
36753774 ExKind::Refined(b) => b.to_string(),
36763775 }
@@ -3678,8 +3777,8 @@ impl<'a> ExKind<'a> {
36783777}
36793778
36803779/// Parse the compiler-emitted descriptor: `Bytes`, `Uuid`, `Rel <scalar>`,
3681−/// `enum:<Name>(<Ctor>,<Ctor>,…)`, `refined:<Name>(<base-scalar>)`, each
3682−/// optionally prefixed by `Maybe `.
3780+/// `Vec <scalar>`, `List <scalar>`, `enum:<Name>(<Ctor>,<Ctor>,…)`,
3781+/// `refined:<Name>(<base-scalar>)`, each optionally prefixed by `Maybe `.
36833782fn parse_ex_descriptor(desc: &str) -> Option<(bool, ExKind<'_>)> {
36843783 let (is_maybe, base) = match desc.strip_prefix("Maybe ") {
36853784 Some(rest) => (true, rest),
@@ -3688,7 +3787,9 @@ fn parse_ex_descriptor(desc: &str) -> Option<(bool, ExKind<'_>)> {
36883787 let kind = match base {
36893788 "Bytes" => ExKind::Bytes,
36903789 "Uuid" => ExKind::Uuid,
3691− _ if base.starts_with("Rel ") => ExKind::Rel(&base[4..]),
3790+ _ if base.starts_with("Rel ") => ExKind::Rel(parse_elem_descriptor(&base[4..])?),
3791+ _ if base.starts_with("Vec ") => ExKind::Vec(parse_elem_descriptor(&base[4..])?),
3792+ _ if base.starts_with("List ") => ExKind::List(parse_elem_descriptor(&base[5..])?),
36923793 _ if base.starts_with("enum:") => {
36933794 let rest = &base[5..];
36943795 let open = rest.find('(')?;
@@ -3715,6 +3816,36 @@ fn parse_ex_descriptor(desc: &str) -> Option<(bool, ExKind<'_>)> {
37153816 Some((is_maybe, kind))
37163817}
37173818
3819+/// Parse a collection element descriptor into an `ElemKind`: a bare scalar
3820+/// (`Int`/`Float`/`Text`/`Bool`), `Bytes`, `Uuid`, an enum (`enum:Name(Ctor,…)`),
3821+/// or a refined type (`refined:Name(base)`).
3822+fn parse_elem_descriptor(elem: &str) -> Option<ElemKind<'_>> {
3823+ match elem {
3824+ "Int" | "Float" | "Text" | "Bool" => Some(ElemKind::Scalar(elem)),
3825+ "Bytes" => Some(ElemKind::Bytes),
3826+ "Uuid" => Some(ElemKind::Uuid),
3827+ _ if elem.starts_with("enum:") => {
3828+ let rest = &elem[5..];
3829+ let open = rest.find('(')?;
3830+ let close = rest.rfind(')')?;
3831+ let ctor_str = &rest[open + 1..close];
3832+ let ctors: Vec<String> = if ctor_str.is_empty() {
3833+ Vec::new()
3834+ } else {
3835+ ctor_str.split(',').map(|s| s.to_string()).collect()
3836+ };
3837+ Some(ElemKind::Enum(Box::leak(ctors.into_boxed_slice())))
3838+ }
3839+ _ if elem.starts_with("refined:") => {
3840+ let rest = &elem[8..];
3841+ let open = rest.find('(')?;
3842+ let close = rest.rfind(')')?;
3843+ Some(ElemKind::Refined(&rest[open + 1..close]))
3844+ }
3845+ _ => None,
3846+ }
3847+}
3848+
37183849/// Decode URL-safe base64 (RFC 4648 §5, `-`/`_`, padding optional). Returns
37193850/// None on any invalid character or length. Rejecting standard-alphabet
37203851/// characters (`+`/`/`) is deliberate: the CLI accepts base64url only.
@@ -3749,24 +3880,56 @@ fn decode_base64url(s: &str) -> Option<Vec<u8>> {
37493880 Some(out)
37503881}
37513882
3752−/// Parse one scalar element for a `Rel <scalar>` override.
3753−fn parse_rel_element(name: &str, val: &str, elem: &str) -> *mut Value {
3883+/// Parse one element for a `Vec`/`Rel`/`List <elem>` override.
3884+fn parse_rel_element(name: &str, val: &str, elem: &ElemKind) -> *mut Value {
37543885 match elem {
3755− "Int" => match val.parse::<i64>() {
3756− Ok(n) => alloc_int(n),
3757− Err(_) => override_parse_error(name, val, "Int"),
3758− },
3759− "Float" => match val.parse::<f64>() {
3760− Ok(n) if n.is_finite() => alloc_float(n),
3761− _ => override_parse_error(name, val, "Float"),
3886+ ElemKind::Scalar(s) => match *s {
3887+ "Int" => match val.parse::<i64>() {
3888+ Ok(n) => alloc_int(n),
3889+ Err(_) => override_parse_error(name, val, "Int"),
3890+ },
3891+ "Float" => match val.parse::<f64>() {
3892+ Ok(n) if n.is_finite() => alloc_float(n),
3893+ _ => override_parse_error(name, val, "Float"),
3894+ },
3895+ "Text" => alloc(Value::Text(Arc::from(val))),
3896+ "Bool" => match val {
3897+ "true" | "True" | "1" => encode_bool(true),
3898+ "false" | "False" | "0" => encode_bool(false),
3899+ _ => override_parse_error(name, val, "true or false"),
3900+ },
3901+ _ => override_parse_error(name, val, "a scalar"),
37623902 },
3763− "Text" => alloc(Value::Text(Arc::from(val))),
3764− "Bool" => match val {
3765− "true" | "True" | "1" => encode_bool(true),
3766− "false" | "False" | "0" => encode_bool(false),
3767− _ => override_parse_error(name, val, "true or false"),
3903+ ElemKind::Bytes => match decode_base64url(val) {
3904+ Some(bytes) => alloc(Value::Bytes(Arc::from(bytes))),
3905+ None => override_parse_error(name, val, "base64url-encoded Bytes"),
37683906 },
3769− _ => override_parse_error(name, val, "a scalar"),
3907+ ElemKind::Uuid => {
3908+ let valid = val.len() == 36
3909+ && val.as_bytes().iter().enumerate().all(|(i, &b)| {
3910+ if i == 8 || i == 13 || i == 18 || i == 23 {
3911+ b == b'-'
3912+ } else {
3913+ b.is_ascii_hexdigit()
3914+ }
3915+ });
3916+ if valid {
3917+ alloc(Value::Text(Arc::from(val)))
3918+ } else {
3919+ override_parse_error(name, val, "a Uuid")
3920+ }
3921+ }
3922+ ElemKind::Enum(ctors) => {
3923+ if ctors.iter().any(|c| c == val) {
3924+ let tag = intern_str(val);
3925+ alloc(Value::Constructor(tag, alloc(Value::Unit)))
3926+ } else {
3927+ override_parse_error(name, val, &format!("one of: {}", ctors.join(", ")))
3928+ }
3929+ }
3930+ // A refined element parses as its base scalar; the per-element
3931+ // predicate check runs in generated code.
3932+ ElemKind::Refined(base) => parse_rel_element(name, val, &ElemKind::Scalar(base)),
37703933 }
37713934}
37723935
@@ -3794,21 +3957,13 @@ pub extern "C-unwind" fn knot_override_lookup_ex(
37943957
37953958 let values = override_flag_values(name, kind.is_bool());
37963959
3797− // A Maybe-wrapped flag explicitly set to "Nothing" means `Nothing {}`.
3798− if is_maybe
3799− && let Some(last) = values.last()
3800− && last == "Nothing"
3801− {
3802− return make_nothing();
3803− }
3804−
3805− if let ExKind::Rel(elem) = &kind {
3960+ if let ExKind::Rel(elem) | ExKind::Vec(elem) | ExKind::List(elem) = &kind {
38063961 if values.is_empty() {
38073962 return std::ptr::null_mut();
38083963 }
38093964 // Each occurrence may be comma-separated; an empty value
38103965 // (`--tag=`) contributes no elements, so `--tag=` alone yields
3811− // the empty relation.
3966+ // the empty collection.
38123967 let mut rows: Vec<*mut Value> = Vec::new();
38133968 for occ in &values {
38143969 for piece in occ.split(',') {
@@ -3818,8 +3973,23 @@ pub extern "C-unwind" fn knot_override_lookup_ex(
38183973 rows.push(parse_rel_element(name, piece, elem));
38193974 }
38203975 }
3821− let rel = alloc(Value::Relation(rows));
3822− return if is_maybe { make_just(rel) } else { rel };
3976+ let v = match &kind {
3977+ // A relation is a set: dedup.
3978+ ExKind::Rel(_) => alloc(Value::Relation(in_memory_dedup(rows))),
3979+ // A vec is ordered and allows duplicates: same representation, no
3980+ // dedup.
3981+ ExKind::Vec(_) => alloc(Value::Relation(rows)),
3982+ // A list is a Cons/Nil chain, built tail-first.
3983+ ExKind::List(_) => {
3984+ let mut acc = list_nil();
3985+ for row in rows.into_iter().rev() {
3986+ acc = list_cons(row, acc);
3987+ }
3988+ acc
3989+ }
3990+ _ => unreachable!(),
3991+ };
3992+ return if is_maybe { make_just(v) } else { v };
38233993 }
38243994
38253995 // Scalar-shaped kinds: last occurrence wins.
@@ -3861,9 +4031,11 @@ pub extern "C-unwind" fn knot_override_lookup_ex(
38614031 ExKind::Refined(base) => {
38624032 // Parse as the base scalar; the refinement predicate is checked
38634033 // by generated code after this returns.
3864− parse_rel_element(name, val_str, base)
4034+ parse_rel_element(name, val_str, &ElemKind::Scalar(base))
4035+ }
4036+ ExKind::Rel(_) | ExKind::Vec(_) | ExKind::List(_) => {
4037+ unreachable!("handled above")
38654038 }
3866− ExKind::Rel(_) => unreachable!("handled above"),
38674039 };
38684040
38694041 if is_maybe {
@@ -3938,6 +4110,74 @@ pub extern "C-unwind" fn knot_override_check_refined(
39384110 }
39394111}
39404112
4113+/// Apply a refined predicate to every element of a collection override
4114+/// (`Vec`/`Rel` — the relation representation — or `List`, a Cons/Nil chain).
4115+/// `pred_fn` is the compiled predicate (a function value); `db` is the live db
4116+/// handle for the call. Aborts naming the flag and type on the first element
4117+/// that fails. No-op when the override is null (flag absent — the default was
4118+/// already checked at compile time).
4119+#[unsafe(no_mangle)]
4120+pub extern "C-unwind" fn knot_override_check_refined_each(
4121+ db: *mut std::ffi::c_void,
4122+ coll: *mut Value,
4123+ pred_fn: *mut Value,
4124+ flag_ptr: *const u8,
4125+ flag_len: usize,
4126+ ty_ptr: *const u8,
4127+ ty_len: usize,
4128+) {
4129+ if coll.is_null() {
4130+ return;
4131+ }
4132+ let flag = unsafe { str_from_raw(flag_ptr, flag_len) }.to_string();
4133+ let ty = unsafe { str_from_raw(ty_ptr, ty_len) }.to_string();
4134+ let check = |elem: *mut Value| {
4135+ let res = knot_value_call(db, pred_fn, elem);
4136+ let ok = matches!(unsafe { as_ref(res) }, Value::Bool(true));
4137+ if !ok {
4138+ eprintln!(
4139+ "Error: value '{}' for --{} does not satisfy predicate for type '{}'",
4140+ display_value(elem),
4141+ flag,
4142+ ty
4143+ );
4144+ std::process::exit(1);
4145+ }
4146+ };
4147+ match unsafe { as_ref(coll) } {
4148+ Value::Relation(rows) => {
4149+ for &row in rows {
4150+ check(row);
4151+ }
4152+ }
4153+ // A List is a Cons/Nil chain: Cons {head, tail}.
4154+ Value::Constructor(..) => {
4155+ let mut cur = coll;
4156+ loop {
4157+ match unsafe { as_ref(cur) } {
4158+ Value::Constructor(tag, payload) if ctor_leaf(tag) == "Cons" => {
4159+ if let Value::Record(fields) = unsafe { as_ref(*payload) } {
4160+ let head = fields.iter().find(|f| &*f.name == "head").map(|f| f.value);
4161+ let tail = fields.iter().find(|f| &*f.name == "tail").map(|f| f.value);
4162+ if let Some(h) = head {
4163+ check(h);
4164+ }
4165+ match tail {
4166+ Some(t) => cur = t,
4167+ None => break,
4168+ }
4169+ } else {
4170+ break;
4171+ }
4172+ }
4173+ _ => break,
4174+ }
4175+ }
4176+ }
4177+ _ => {}
4178+ }
4179+}
4180+
39414181/// Decode a default-value string emitted by the compiler.
39424182///
39434183/// The compiler escapes `\` as `\\` and `,` as `\c` so the entry
@@ -4392,6 +4632,10 @@ pub struct FunctionInner {
43924632 /// qualified, re-parseable `Maybe.Nothing {}`). `None` → extract derives
43934633 /// from `source` + `env` as usual.
43944634 pub extract_source: Option<Arc<str>>,
4635+ /// Keep-alive handle for the JIT module this function's `fn_ptr` points
4636+ /// into. Null for natively-compiled (AOT) functions, whose code lives in
4637+ /// the process image and never needs freeing. See [`CodeHandle`].
4638+ pub code: *const CodeHandle,
43954639}
43964640
43974641/// Payload for `Value::IO`: boxed to keep `Value` compact. `source` is the
@@ -4402,6 +4646,128 @@ pub struct IOInner {
44024646 pub fn_ptr: *const u8,
44034647 pub env: *mut Value,
44044648 pub source: Arc<str>,
4649+ /// Keep-alive handle for the JIT module this thunk's `fn_ptr` points into.
4650+ /// Null for AOT-compiled actions. See [`CodeHandle`].
4651+ pub code: *const CodeHandle,
4652+}
4653+
4654+/// A type-erased, refcounted keep-alive handle for a JIT'd code module.
4655+///
4656+/// A `Value::Function`/`Value::IO` produced by `base.compile` holds a `fn_ptr`
4657+/// into the JIT module's executable memory, which must stay mapped while any
4658+/// such value is reachable. But knot-runtime can't name the JIT's module type
4659+/// (that would be a dependency cycle), so the module's lifetime is exposed as
4660+/// two function pointers plus an opaque refcount word:
4661+///
4662+/// - `refcount` is a `*const AtomicUsize` owned by the JIT side.
4663+/// - `free` reclaims the module; the caller (here, on drop of the last
4664+/// function/IO referencing it) invokes it exactly once, when the shared
4665+/// refcount reaches zero.
4666+///
4667+/// Cloning a function/IO increments the refcount; dropping one decrements it.
4668+/// The arena's `reset_to`/frame-drop paths run value destructors, which is what
4669+/// drives the decrement — so a JIT module is freed precisely when the last
4670+/// closure pointing into it is reclaimed.
4671+///
4672+/// # Safety
4673+/// `refcount` and `free` must remain valid until the refcount reaches zero and
4674+/// `free` has been called. The JIT side guarantees this by leaking the handle's
4675+/// storage into the module's own lifetime.
4676+///
4677+/// `repr(C)` pins the field order — knot-jit constructs this layout via a
4678+/// mirror struct, so the two must agree byte-for-byte.
4679+#[repr(C)]
4680+pub struct CodeHandle {
4681+ pub refcount: *const std::sync::atomic::AtomicUsize,
4682+ pub free: unsafe extern "C" fn(*const std::sync::atomic::AtomicUsize),
4683+}
4684+
4685+/// Increment a code handle's refcount (a new function/IO now references the
4686+/// module). Null-safe: a null handle (AOT code) is a no-op.
4687+///
4688+/// # Safety
4689+/// `handle` must be null or point to a live [`CodeHandle`].
4690+pub unsafe fn code_handle_retain(handle: *const CodeHandle) {
4691+ if handle.is_null() {
4692+ return;
4693+ }
4694+ unsafe {
4695+ (*(*handle).refcount).fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4696+ }
4697+}
4698+
4699+/// Decrement a code handle's refcount; on the last drop, free the module.
4700+/// Null-safe.
4701+///
4702+/// # Safety
4703+/// `handle` must be null or a [`CodeHandle`] whose refcount is above zero.
4704+pub unsafe fn code_handle_release(handle: *const CodeHandle) {
4705+ if handle.is_null() {
4706+ return;
4707+ }
4708+ unsafe {
4709+ // Release so prior writes are visible to whoever frees; Acquire on the
4710+ // final decrement pairs with it. On zero, hand the refcount word to the
4711+ // JIT's free routine, which reclaims the module (and the word).
4712+ if (*(*handle).refcount).fetch_sub(1, std::sync::atomic::Ordering::AcqRel) == 1 {
4713+ ((*handle).free)((*handle).refcount);
4714+ }
4715+ }
4716+}
4717+
4718+/// Recursively stamp a JIT code handle onto every `Function`/`IO` reachable
4719+/// from `v` that doesn't already have one (those have `code == null`). Sets the
4720+/// refcount to the number of values stamped, so the count matches the number of
4721+/// code-bearing values sharing the module. Called once by the JIT right after a
4722+/// `base.compile` body returns, before the value escapes to the host.
4723+///
4724+/// Depth-bounded against pathological values. Values that already carry a
4725+/// handle (e.g. a closure captured from an earlier `compile`) are left alone —
4726+/// their module is already accounted for.
4727+///
4728+/// # Safety
4729+/// `v` must be a live `Value*` produced by this runtime; `handle` a valid
4730+/// [`CodeHandle`] whose refcount is about to be initialized by this call.
4731+#[unsafe(no_mangle)]
4732+pub unsafe extern "C-unwind" fn knot_value_set_code(v: *mut Value, handle: *const CodeHandle) {
4733+ if v.is_null() || handle.is_null() {
4734+ return;
4735+ }
4736+ fn stamp(v: *mut Value, handle: *const CodeHandle, depth: usize) -> usize {
4737+ if v.is_null() || depth > 512 {
4738+ return 0;
4739+ }
4740+ match unsafe { as_ref(v) } {
4741+ Value::Function(f) => {
4742+ if f.code.is_null() {
4743+ // Cast away the shared ref to write the field once.
4744+ unsafe { (*(f.as_ref() as *const FunctionInner as *mut FunctionInner)).code = handle };
4745+ 1
4746+ } else {
4747+ 0
4748+ }
4749+ }
4750+ Value::IO(io) => {
4751+ if io.code.is_null() {
4752+ unsafe { (*(io.as_ref() as *const IOInner as *mut IOInner)).code = handle };
4753+ 1
4754+ } else {
4755+ 0
4756+ }
4757+ }
4758+ Value::Record(fields) => fields.iter().map(|f| stamp(f.value, handle, depth + 1)).sum(),
4759+ Value::Relation(elems) => elems.iter().map(|e| stamp(*e, handle, depth + 1)).sum(),
4760+ Value::Constructor(_, payload) => stamp(*payload, handle, depth + 1),
4761+ Value::Pair(a, b) => stamp(*a, handle, depth + 1) + stamp(*b, handle, depth + 1),
4762+ _ => 0,
4763+ }
4764+ }
4765+ let stamped = stamp(v, handle, 0);
4766+ if stamped > 0 {
4767+ unsafe {
4768+ (*(*handle).refcount).store(stamped, std::sync::atomic::Ordering::Relaxed);
4769+ }
4770+ }
44054771}
44064772
44074773pub struct RecordField {
@@ -4412,6 +4778,87 @@ pub struct RecordField {
44124778 pub value: *mut Value,
44134779}
44144780
4781+// Dropping a function/IO releases its JIT code handle; when the last value
4782+// referencing a module drops, the module's code memory is freed. The arena
4783+// runs these destructors on every reclaim path (`reset_to`, frame drop), so a
4784+// JIT module lives exactly as long as its longest-lived closure.
4785+impl Drop for FunctionInner {
4786+ fn drop(&mut self) {
4787+ unsafe { code_handle_release(self.code) };
4788+ }
4789+}
4790+
4791+impl Drop for IOInner {
4792+ fn drop(&mut self) {
4793+ unsafe { code_handle_release(self.code) };
4794+ }
4795+}
4796+
4797+#[cfg(test)]
4798+mod code_handle_tests {
4799+ use super::*;
4800+ use std::sync::atomic::{AtomicUsize, Ordering};
4801+
4802+ // A fake "module": a boxed (refcount, freed-flag, handle). `free` flips the
4803+ // freed flag via the refcount's container, mirroring how the JIT's block
4804+ // reclaims its module when the last closure drops.
4805+ #[repr(C)]
4806+ struct FakeBlock {
4807+ refcount: AtomicUsize,
4808+ freed: std::sync::atomic::AtomicBool,
4809+ handle: CodeHandle,
4810+ }
4811+
4812+ unsafe extern "C" fn fake_free(refcount: *const AtomicUsize) {
4813+ let block = refcount as *mut FakeBlock;
4814+ unsafe {
4815+ (*block).freed.store(true, Ordering::SeqCst);
4816+ drop(Box::from_raw(block));
4817+ }
4818+ }
4819+
4820+ fn new_block(count: usize) -> (*mut FakeBlock, *const CodeHandle) {
4821+ let mut b = Box::new(FakeBlock {
4822+ refcount: AtomicUsize::new(count),
4823+ freed: std::sync::atomic::AtomicBool::new(false),
4824+ handle: CodeHandle {
4825+ refcount: std::ptr::null(),
4826+ free: fake_free,
4827+ },
4828+ });
4829+ b.handle.refcount = &raw const b.refcount;
4830+ let handle = &raw const b.handle;
4831+ (Box::into_raw(b), handle)
4832+ }
4833+
4834+ #[test]
4835+ fn release_to_zero_frees() {
4836+ let (block, handle) = new_block(2);
4837+ unsafe { code_handle_release(handle) }; // 2 -> 1
4838+ assert!(!unsafe { (*block).freed.load(Ordering::SeqCst) });
4839+ let freed_flag = unsafe { (*block).freed.load(Ordering::SeqCst) };
4840+ assert!(!freed_flag);
4841+ unsafe { code_handle_release(handle) }; // 1 -> 0 → free
4842+ // block is now freed; we can't read it. The test not crashing/hanging is
4843+ // the assertion; freed was set inside fake_free before the Box drop.
4844+ }
4845+
4846+ #[test]
4847+ fn retain_then_release_balances() {
4848+ let (block, handle) = new_block(1);
4849+ unsafe { code_handle_retain(handle) }; // 1 -> 2
4850+ unsafe { code_handle_release(handle) }; // 2 -> 1
4851+ assert!(!unsafe { (*block).freed.load(Ordering::SeqCst) });
4852+ unsafe { code_handle_release(handle) }; // 1 -> 0 → free
4853+ }
4854+
4855+ #[test]
4856+ fn null_handle_is_noop() {
4857+ unsafe { code_handle_retain(std::ptr::null()) };
4858+ unsafe { code_handle_release(std::ptr::null()) };
4859+ }
4860+}
4861+
44154862impl Drop for Value {
44164863 fn drop(&mut self) {
44174864 // Custom Drop intercepts `Value::Relation` / `Value::Record` to
@@ -4958,6 +5405,7 @@ fn alloc_io(fn_ptr: *const u8, env: *mut Value, source: Arc<str>) -> *mut Value
49585405 fn_ptr,
49595406 env,
49605407 source,
5408+ code: std::ptr::null(),
49615409 })))
49625410}
49635411
@@ -5592,6 +6040,7 @@ pub extern "C-unwind" fn knot_value_function(
55926040 env,
55936041 source,
55946042 extract_source: None,
6043+ code: std::ptr::null(),
55956044 })))
55966045}
55976046
@@ -5615,6 +6064,7 @@ pub extern "C-unwind" fn knot_value_function_full(
56156064 env,
56166065 source,
56176066 extract_source,
6067+ code: std::ptr::null(),
56186068 })))
56196069}
56206070
@@ -8574,6 +9024,11 @@ pub extern "C-unwind" fn knot_value_call(
85749024) -> *mut Value {
85759025 match unsafe { as_ref(func) } {
85769026 Value::Function(f) => {
9027+ // Establish the callee's db as this thread's current-db for the
9028+ // duration of the call, so a `base.compile` inside it (or inside a
9029+ // deeper call) runs its snippet on this handle — sharing any
9030+ // enclosing `atomic` SAVEPOINT — then restore the caller's.
9031+ let _db_scope = RestoreDb::new(db);
85779032 let fun: extern "C-unwind" fn(*mut c_void, *mut Value, *mut Value) -> *mut Value =
85789033 unsafe { std::mem::transmute(f.fn_ptr) };
85799034 fun(db, f.env, arg)
@@ -9242,6 +9697,9 @@ pub extern "C-unwind" fn knot_io_pure(val: *mut Value) -> *mut Value {
92429697/// Execute an IO thunk. If the value is not IO, return it as-is.
92439698#[unsafe(no_mangle)]
92449699pub extern "C-unwind" fn knot_io_run(db: *mut c_void, val: *mut Value) -> *mut Value {
9700+ // Current-db for the whole IO chain: any `base.compile` reached while
9701+ // running these thunks runs on this handle (sharing an enclosing atomic).
9702+ let _db_scope = RestoreDb::new(db);
92459703 let mut current = val;
92469704 loop {
92479705 // Cooperative cancellation: a `race` loser exits its IO chain at the
@@ -9458,19 +9916,23 @@ fn deep_clone_with(val: *mut Value, mut alloc_node: impl FnMut(Value) -> *mut Va
94589916 }
94599917 Value::Function(f) => {
94609918 to_alloc.push(f.env);
9919+ unsafe { code_handle_retain(f.code) };
94619920 Value::Function(Box::new(FunctionInner {
94629921 fn_ptr: f.fn_ptr,
94639922 env: std::ptr::null_mut(),
94649923 source: f.source.clone(),
94659924 extract_source: f.extract_source.clone(),
9925+ code: f.code,
94669926 }))
94679927 }
94689928 Value::IO(io) => {
94699929 to_alloc.push(io.env);
9930+ unsafe { code_handle_retain(io.code) };
94709931 Value::IO(Box::new(IOInner {
94719932 fn_ptr: io.fn_ptr,
94729933 env: std::ptr::null_mut(),
94739934 source: io.source.clone(),
9935+ code: io.code,
94749936 }))
94759937 }
94769938 Value::Pair(a, b) => {
@@ -10740,6 +11202,7 @@ pub extern "C-unwind" fn knot_trace(
1074011202 env,
1074111203 source: Arc::from("trace"),
1074211204 extract_source: None,
11205+ code: std::ptr::null(),
1074311206 })))
1074411207}
1074511208
@@ -11586,43 +12049,112 @@ type CompileImpl = unsafe extern "C" fn(
1158612049static COMPILE_IMPL: std::sync::atomic::AtomicPtr<c_void> =
1158712050 std::sync::atomic::AtomicPtr::new(std::ptr::null_mut());
1158812051
11589−/// Registered by knot-compile-rt at program startup to install the compile
11590−/// implementation. Called from the generated program's `main`.
11591−#[unsafe(no_mangle)]
11592−pub extern "C-unwind" fn knot_register_compile_impl(f: *mut c_void) {
11593− COMPILE_IMPL.store(f, std::sync::atomic::Ordering::Release);
12052+/// The successful result of running a compile closure: the value plus the
12053+/// optional body-type descriptor and the snippet's declared relations, to hand
12054+/// back through the FFI out-params.
12055+pub struct CompileOutcome {
12056+ pub value: *mut Value,
12057+ pub ty: Option<String>,
12058+ pub relations: Vec<String>,
1159412059}
1159512060
11596−/// Is `v` free of any JIT'd code reference? A compiled snippet's JIT module
11597−/// may be freed only when its result cannot re-enter the module's code. Only
11598−/// `Function` and `IO` values carry a `fn_ptr` into that code (a lambda or an
11599−/// IO thunk); every other variant is pure data whose children are values, not
11600−/// code. Recurses through records, relations, constructor payloads, and pairs.
11601−/// `1` = code-free (module safe to free), `0` = holds code (must keep the
11602−/// module alive).
12061+/// Null the three out-param pairs so a compile error leaves them well-defined.
1160312062///
1160412063/// # Safety
11605−/// `v` must be a live `Value*` produced by this runtime.
11606−#[unsafe(no_mangle)]
11607−pub extern "C-unwind" fn knot_value_is_code_free(v: *mut Value) -> i32 {
11608− if v.is_null() {
11609− return 1;
12064+/// Each non-null pointer must be valid for writes of its payload.
12065+pub unsafe fn compile_out_params_init(pairs: [(*mut *mut u8, *mut usize); 3]) {
12066+ unsafe {
12067+ for (p, l) in pairs {
12068+ if !p.is_null() {
12069+ *p = std::ptr::null_mut();
12070+ }
12071+ if !l.is_null() {
12072+ *l = 0;
12073+ }
12074+ }
1161012075 }
11611− // Depth-bounded: a pathological cyclic value can't overflow the stack.
11612− fn go(v: *mut Value, depth: usize) -> bool {
11613− if v.is_null() || depth > 512 {
11614− return depth > 512; // too deep to prove code-free → keep the module
12076+}
12077+
12078+/// Hand a string back as a malloc'd buffer (C ABI — the caller frees it).
12079+///
12080+/// # Safety
12081+/// `ptr`/`len`, if non-null, must be valid for writes.
12082+pub unsafe fn compile_write_out(s: &str, ptr: *mut *mut u8, len: *mut usize) {
12083+ if ptr.is_null() || len.is_null() {
12084+ return;
12085+ }
12086+ let buf = unsafe { libc::malloc(s.len().max(1)) as *mut u8 };
12087+ if buf.is_null() {
12088+ return;
12089+ }
12090+ unsafe {
12091+ std::ptr::copy_nonoverlapping(s.as_ptr(), buf, s.len());
12092+ *ptr = buf;
12093+ *len = s.len();
12094+ }
12095+}
12096+
12097+/// Run a `base.compile` compile+run closure behind the FFI boundary, containing
12098+/// any panic. `compile` is the compiler- and backend-specific work; it returns
12099+/// `Ok(Some(outcome))` on success, `Ok(None)` on a clean compile error, and any
12100+/// panic is caught here. On success the outcome's ty/relations are written to
12101+/// the out-params and the value returned; on error or panic the (already
12102+/// nulled) out-params get the message in `out_err` and null is returned, so the
12103+/// runtime surfaces a `Result.Err` instead of unwinding into the host.
12104+///
12105+/// This is the single containment point shared by every `knot_compile_impl`
12106+/// (the compiler binary's and the `knot-compile-rt` one a compiled program
12107+/// links), so the two cannot drift apart.
12108+///
12109+/// # Safety
12110+/// `db` must be a live db handle; the out-param pointers, if non-null, must be
12111+/// valid for writes of their payload. They must already be nulled via
12112+/// [`compile_out_params_init`] by the caller.
12113+#[allow(clippy::too_many_arguments)]
12114+pub unsafe fn run_compile_contained(
12115+ db: *mut c_void,
12116+ out_ty_ptr: *mut *mut u8,
12117+ out_ty_len: *mut usize,
12118+ out_rels_ptr: *mut *mut u8,
12119+ out_rels_len: *mut usize,
12120+ out_err_ptr: *mut *mut u8,
12121+ out_err_len: *mut usize,
12122+ compile: impl FnOnce(*mut c_void) -> Option<CompileOutcome>,
12123+) -> *mut Value {
12124+ // The raw pointers are !UnwindSafe, so assert it; the closure only reads
12125+ // them and the out-params were nulled by the caller, so no broken state is
12126+ // observable across a caught panic.
12127+ let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| compile(db)));
12128+ match r {
12129+ Ok(Some(outcome)) => {
12130+ if let Some(ty) = &outcome.ty {
12131+ unsafe { compile_write_out(ty, out_ty_ptr, out_ty_len) };
12132+ }
12133+ let csv = outcome.relations.join(",");
12134+ unsafe { compile_write_out(&csv, out_rels_ptr, out_rels_len) };
12135+ outcome.value
1161512136 }
11616− match unsafe { as_ref(v) } {
11617− Value::Function(_) | Value::IO(_) => false,
11618− Value::Record(fields) => fields.iter().all(|f| go(f.value, depth + 1)),
11619− Value::Relation(elems) => elems.iter().all(|e| go(*e, depth + 1)),
11620− Value::Constructor(_, payload) => go(*payload, depth + 1),
11621− Value::Pair(a, b) => go(*a, depth + 1) && go(*b, depth + 1),
11622− _ => true,
12137+ Ok(None) => std::ptr::null_mut(),
12138+ Err(payload) => {
12139+ let msg = payload
12140+ .downcast_ref::<&str>()
12141+ .map(|s| (*s).to_string())
12142+ .or_else(|| payload.downcast_ref::<String>().cloned())
12143+ .unwrap_or_else(|| "unknown panic".to_string());
12144+ unsafe {
12145+ compile_write_out(&format!("compile panicked: {msg}"), out_err_ptr, out_err_len)
12146+ };
12147+ std::ptr::null_mut()
1162312148 }
1162412149 }
11625− go(v, 0) as i32
12150+}
12151+
12152+
12153+/// Registered by knot-compile-rt at program startup to install the compile
12154+/// implementation. Called from the generated program's `main`.
12155+#[unsafe(no_mangle)]
12156+pub extern "C-unwind" fn knot_register_compile_impl(f: *mut c_void) {
12157+ COMPILE_IMPL.store(f, std::sync::atomic::Ordering::Release);
1162612158}
1162712159
1162812160/// Look up the registered compile implementation, if any.
@@ -11654,16 +12186,28 @@ fn compile_run(
1165412186 Some("no JIT linked (knot-compile-rt not registered)".into()),
1165512187 );
1165612188 };
11657− // Open a db handle for the snippet. knot_db_open records the path on first
11658− // open (DB_PATH), so reuse it; a pure program (no store) gets :memory:.
11659− // A snippet reading the host's persisted relations thus sees real data.
11660− let db_path = DB_PATH.lock().unwrap_or_else(|e| e.into_inner()).clone();
11661− let (pstr, plen) = if db_path.is_empty() {
11662− (":memory:".as_bytes().as_ptr(), ":memory:".len())
11663− } else {
11664− (db_path.as_ptr(), db_path.len())
12189+ // Run the snippet on the CALLER's live db handle when one is active
12190+ // (the normal case: the host program called `base.compile` from running
12191+ // code, so CURRENT_DB holds its handle). This makes the compiled value
12192+ // share the host's connection — and any enclosing `atomic` SAVEPOINT — so
12193+ // its reads see uncommitted writes and its writes roll back with the
12194+ // transaction. When there is no caller (the compiler's const-eval thread),
12195+ // fall back to opening a connection: reuse the recorded host path so a
12196+ // snippet reading persisted relations sees real data, else `:memory:`.
12197+ let db = {
12198+ let cur = current_db();
12199+ if !cur.is_null() {
12200+ cur
12201+ } else {
12202+ let db_path = DB_PATH.lock().unwrap_or_else(|e| e.into_inner()).clone();
12203+ let (pstr, plen) = if db_path.is_empty() {
12204+ (":memory:".as_bytes().as_ptr(), ":memory:".len())
12205+ } else {
12206+ (db_path.as_ptr(), db_path.len())
12207+ };
12208+ knot_db_open(pstr, plen)
12209+ }
1166512210 };
11666− let db = knot_db_open(pstr, plen);
1166712211
1166812212 // The host's expected type (source-annotation string) forwarded to the JIT,
1166912213 // which runs the real-type subsumption. Empty → unpinned.
@@ -11723,12 +12267,15 @@ fn compile_run(
1172312267 free_ty(ty_ptr);
1172412268 return (std::ptr::null_mut(), None, err);
1172512269 }
11726− // Foreign-`*rel` guard.
12270+ // Foreign-`*rel` guard (defense-in-depth — the JIT already validated each
12271+ // relation's name AND shape against the host before running the snippet;
12272+ // see knot_compile_host_rel_matches). Each entry is `name\tdescriptor`.
1172712273 if !rels_csv.is_empty() {
1172812274 let host_rels = SOURCE_SCHEMAS.read().unwrap_or_else(|e| e.into_inner());
11729− let foreign = rels_csv
11730− .split(',')
11731− .any(|r| !r.is_empty() && !host_rels.contains_key(r));
12275+ let foreign = rels_csv.split(',').filter(|e| !e.is_empty()).any(|entry| {
12276+ let name = entry.split_once('\t').map(|(n, _)| n).unwrap_or(entry);
12277+ !host_rels.contains_key(name)
12278+ });
1173212279 drop(host_rels);
1173312280 if foreign {
1173412281 free_ty(ty_ptr);
@@ -13641,6 +14188,73 @@ fn read_schema_cursor(conn: &rusqlite::Connection, name: &str) -> Option<(String
1364114188 .ok()
1364214189}
1364314190
14191+/// One migration step baked into the binary, recorded at startup for
14192+/// `db info`: the `from`/`to` descriptors and the migration fn source.
14193+#[derive(Clone, Debug)]
14194+pub struct MigrationStep {
14195+ pub step_idx: i64,
14196+ pub from_schema: String,
14197+ pub to_schema: String,
14198+ pub using_src: String,
14199+}
14200+
14201+/// The migration chain per source, recorded as the binary registers each step
14202+/// at startup. Lets `db info` show the full migration history (which lives in
14203+/// the binary, not the db) alongside the db's current cursor.
14204+static MIGRATION_CHAINS: std::sync::LazyLock<RwLock<HashMap<String, Vec<MigrationStep>>>> =
14205+ std::sync::LazyLock::new(|| RwLock::new(HashMap::new()));
14206+
14207+/// Record one migration step for `db info`. Idempotent per (source, step_idx)
14208+/// — re-registration (e.g. a rebuilt binary with the same chain) replaces it.
14209+fn register_migration_step(source: &str, step: MigrationStep) {
14210+ let mut chains = MIGRATION_CHAINS.write().unwrap_or_else(|e| e.into_inner());
14211+ let chain = chains.entry(source.to_string()).or_default();
14212+ match chain.iter_mut().find(|s| s.step_idx == step.step_idx) {
14213+ Some(existing) => *existing = step,
14214+ None => chain.push(step),
14215+ }
14216+ chain.sort_by_key(|s| s.step_idx);
14217+}
14218+
14219+/// `<program>` startup registers each baked-in migration step so `db info` can
14220+/// show the full per-source chain (which lives in the binary, not the db).
14221+/// `from`/`to` are the step's descriptors, `using_src` the migration fn source.
14222+#[unsafe(no_mangle)]
14223+pub extern "C-unwind" fn knot_register_migration_step(
14224+ source_ptr: *const u8,
14225+ source_len: usize,
14226+ step_idx: i64,
14227+ from_ptr: *const u8,
14228+ from_len: usize,
14229+ to_ptr: *const u8,
14230+ to_len: usize,
14231+ using_ptr: *const u8,
14232+ using_len: usize,
14233+) {
14234+ let source = unsafe { str_from_raw(source_ptr, source_len) };
14235+ register_migration_step(
14236+ source,
14237+ MigrationStep {
14238+ step_idx,
14239+ from_schema: unsafe { str_from_raw(from_ptr, from_len) }.to_string(),
14240+ to_schema: unsafe { str_from_raw(to_ptr, to_len) }.to_string(),
14241+ using_src: unsafe { str_from_raw(using_ptr, using_len) }.to_string(),
14242+ },
14243+ );
14244+}
14245+
14246+
14247+/// Read the recorded chain for a source (empty if none registered).
14248+fn migration_chain_for(source: &str) -> Vec<MigrationStep> {
14249+ MIGRATION_CHAINS
14250+ .read()
14251+ .unwrap_or_else(|e| e.into_inner())
14252+ .get(source)
14253+ .cloned()
14254+ .unwrap_or_default()
14255+}
14256+
14257+
1364414258/// Apply a migration to a source relation.
1364514259///
1364614260/// Checks the stored schema in `_knot_schema`:
@@ -14069,6 +14683,9 @@ pub extern "C-unwind" fn knot_run_user_main(
1406914683) -> *mut c_void {
1407014684 let f: extern "C-unwind" fn(*mut c_void) -> *mut c_void =
1407114685 unsafe { std::mem::transmute(main_fn) };
14686+ // Establish the program's db as current-db so a top-level `base.compile`
14687+ // (before any knot_value_call/knot_io_run) runs its snippet on this handle.
14688+ let _db_scope = RestoreDb::new(db);
1407214689 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(db))) {
1407314690 Ok(v) => v,
1407414691 Err(payload) => {
@@ -19049,6 +19666,7 @@ fn build_request_ctx(
1904919666 env: header_env,
1905019667 source: intern_str("RequestCtx.header"),
1905119668 extract_source: None,
19669+ code: std::ptr::null(),
1905219670 })));
1905319671 let mut fields = vec![
1905419672 RecordField {
@@ -19490,6 +20108,46 @@ pub extern "C-unwind" fn knot_constraint_register(
1949020108
1949120109// ── Atomic (transactions) ─────────────────────────────────────────
1949220110
20111+thread_local! {
20112+ /// The db handle of the knot code currently executing on this thread —
20113+ /// the handle the innermost active `knot_user_main`/`knot_value_call`/
20114+ /// `knot_io_run` was invoked with. `base.compile` runs its snippet on this
20115+ /// handle (rather than opening a fresh connection) so a compiled value used
20116+ /// inside the host's `atomic` block shares the host's SAVEPOINT: its reads
20117+ /// see uncommitted writes and its own writes roll back with the transaction.
20118+ ///
20119+ /// Set on entry to the foreign-boundary crossings (`knot_run_user_main`,
20120+ /// `knot_value_call`, `knot_io_run`) and restored on return, so a nested
20121+ /// `compile`'s snippet doesn't clobber the host's current-db after the
20122+ /// snippet returns. Null outside any knot execution (e.g. the compiler's
20123+ /// const-eval thread), where `compile` falls back to its own connection.
20124+ static CURRENT_DB: std::cell::Cell<*mut c_void> = const { std::cell::Cell::new(std::ptr::null_mut()) };
20125+}
20126+
20127+/// Set the current-db for this thread, returning the previous value so the
20128+/// caller can restore it on scope exit (see [`RestoreDb`]).
20129+fn set_current_db(db: *mut c_void) -> *mut c_void {
20130+ CURRENT_DB.with(|c| c.replace(db))
20131+}
20132+
20133+/// RAII guard restoring the previous current-db on drop.
20134+struct RestoreDb(*mut c_void);
20135+impl RestoreDb {
20136+ fn new(db: *mut c_void) -> Self {
20137+ RestoreDb(set_current_db(db))
20138+ }
20139+}
20140+impl Drop for RestoreDb {
20141+ fn drop(&mut self) {
20142+ set_current_db(self.0);
20143+ }
20144+}
20145+
20146+/// The current thread's db handle, or null when outside knot execution.
20147+fn current_db() -> *mut c_void {
20148+ CURRENT_DB.with(|c| c.get())
20149+}
20150+
1949320151#[unsafe(no_mangle)]
1949420152pub extern "C-unwind" fn knot_atomic_begin(db: *mut c_void) {
1949520153 let _guard = write_lock_guard();
@@ -23077,6 +23735,231 @@ pub extern "C-unwind" fn knot_api_handle(argc: i32, argv: *const *const u8) -> i
2307723735/// Handle `<program> db` subcommand: launch TUI database explorer.
2307823736/// Returns 1 if handled (caller should exit), 0 otherwise.
2307923737#[unsafe(no_mangle)]
23738+/// Read a NUL-terminated C-string argument (arg index `i`) from argv.
23739+unsafe fn c_arg(argv: *const *const u8, i: i32) -> String {
23740+ unsafe {
23741+ let ptr = *argv.add(i as usize);
23742+ let mut len = 0;
23743+ while *ptr.add(len) != 0 {
23744+ len += 1;
23745+ }
23746+ String::from_utf8_lossy(std::slice::from_raw_parts(ptr, len)).to_string()
23747+ }
23748+}
23749+
23750+/// Minimal ANSI styling for `db info`, applied only on a TTY (plain otherwise,
23751+/// so piped output stays clean).
23752+#[derive(Clone, Copy)]
23753+struct Style {
23754+ tty: bool,
23755+}
23756+
23757+impl Style {
23758+ fn detect() -> Self {
23759+ Style {
23760+ tty: std::io::IsTerminal::is_terminal(&std::io::stdout()),
23761+ }
23762+ }
23763+ fn wrap(&self, code: &str, s: &str) -> String {
23764+ if self.tty {
23765+ format!("\x1b[{}m{}\x1b[0m", code, s)
23766+ } else {
23767+ s.to_string()
23768+ }
23769+ }
23770+ fn bold(&self, s: &str) -> String {
23771+ self.wrap("1", s)
23772+ }
23773+ fn dim(&self, s: &str) -> String {
23774+ self.wrap("2", s)
23775+ }
23776+ fn cyan(&self, s: &str) -> String {
23777+ self.wrap("36", s)
23778+ }
23779+ fn green(&self, s: &str) -> String {
23780+ self.wrap("32", s)
23781+ }
23782+ fn yellow(&self, s: &str) -> String {
23783+ self.wrap("33", s)
23784+ }
23785+}
23786+
23787+/// Human-readable byte size (e.g. `56.0 KB`).
23788+fn human_bytes(n: u64) -> String {
23789+ const KB: u64 = 1024;
23790+ const MB: u64 = KB * 1024;
23791+ const GB: u64 = MB * 1024;
23792+ if n >= GB {
23793+ format!("{:.1} GB", n as f64 / GB as f64)
23794+ } else if n >= MB {
23795+ format!("{:.1} MB", n as f64 / MB as f64)
23796+ } else if n >= KB {
23797+ format!("{:.1} KB", n as f64 / KB as f64)
23798+ } else {
23799+ format!("{} B", n)
23800+ }
23801+}
23802+
23803+/// `<program> db info` — print a human-readable report of the database in knot
23804+/// terms: file summary, then per source its current schema descriptor (the db's
23805+/// actual state), the full baked-in migration chain, and the row count.
23806+fn db_info(db_path: &str) {
23807+ let st = Style::detect();
23808+ let conn = match Connection::open(db_path) {
23809+ Ok(c) => c,
23810+ Err(e) => {
23811+ eprintln!("knot db info: cannot open {}: {}", db_path, e);
23812+ std::process::exit(1);
23813+ }
23814+ };
23815+
23816+ // ── Header: file summary ──
23817+ let size = std::fs::metadata(db_path).map(|m| m.len()).unwrap_or(0);
23818+ let pages: i64 = conn
23819+ .query_row("PRAGMA page_count", [], |r| r.get(0))
23820+ .unwrap_or(0);
23821+ let page_size: i64 = conn
23822+ .query_row("PRAGMA page_size", [], |r| r.get(0))
23823+ .unwrap_or(0);
23824+ let journal: String = conn
23825+ .query_row("PRAGMA journal_mode", [], |r| r.get(0))
23826+ .unwrap_or_default();
23827+ println!("{}", st.bold(db_path));
23828+ println!(
23829+ "{}",
23830+ st.dim(&format!(
23831+ "{} · {} pages × {} B · {}",
23832+ human_bytes(size),
23833+ pages,
23834+ page_size,
23835+ journal
23836+ ))
23837+ );
23838+ println!();
23839+
23840+ // ── Sources ──
23841+ let mut stmt = match conn
23842+ .prepare("SELECT name, schema, applied FROM _knot_schema ORDER BY name")
23843+ {
23844+ Ok(s) => s,
23845+ Err(_) => {
23846+ println!("{}", st.dim("(empty database — no sources)"));
23847+ return;
23848+ }
23849+ };
23850+ let sources: Vec<(String, String, i64)> = stmt
23851+ .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
23852+ .map(|r| r.filter_map(|x| x.ok()).collect())
23853+ .unwrap_or_default();
23854+
23855+ if sources.is_empty() {
23856+ println!("{}", st.dim("(empty database — no sources)"));
23857+ return;
23858+ }
23859+
23860+ println!("{}", st.bold(&format!("{} source{}", sources.len(), if sources.len() == 1 { "" } else { "s" })));
23861+ println!();
23862+
23863+ // Per-relation on-disk footprint: every physical table/index belonging to a
23864+ // source, summed from dbstat. A source `users` owns `_knot_users` (main
23865+ // table), `_knot_users/…` (child/link tables for nested fields), and
23866+ // `_knot_users_…` (its indexes). We match against the *known* source names
23867+ // (longest prefix wins) so a source name containing `_`/`/` isn't split
23868+ // wrongly. Falls back to None when dbstat isn't compiled in.
23869+ let source_names: Vec<&str> = sources.iter().map(|(n, _, _)| n.as_str()).collect();
23870+ let relation_bytes: Option<std::collections::HashMap<String, u64>> = conn
23871+ .prepare("SELECT name, pgsize FROM dbstat")
23872+ .ok()
23873+ .map(|mut s| {
23874+ let mut map: std::collections::HashMap<String, u64> = std::collections::HashMap::new();
23875+ let rows = s.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)));
23876+ if let Ok(rows) = rows {
23877+ for r in rows.flatten() {
23878+ let (tbl, bytes) = (r.0, r.1 as u64);
23879+ let Some(rest) = tbl.strip_prefix("_knot_") else { continue };
23880+ if rest == "schema" {
23881+ continue;
23882+ }
23883+ // Longest known source that is `rest` exactly, or a prefix
23884+ // followed by `_` (index) or `/` (child table).
23885+ let owner = source_names
23886+ .iter()
23887+ .filter(|src| {
23888+ let s: &str = src;
23889+ rest == s
23890+ || rest
23891+ .strip_prefix(s)
23892+ .is_some_and(|t| t.starts_with('_') || t.starts_with('/'))
23893+ })
23894+ .max_by_key(|src| src.len());
23895+ if let Some(owner) = owner {
23896+ *map.entry(owner.to_string()).or_insert(0) += bytes;
23897+ }
23898+ }
23899+ }
23900+ map
23901+ });
23902+
23903+ for (name, schema, applied) in &sources {
23904+ let table = quote_ident(&format!("_knot_{}", name));
23905+ let rows: i64 = conn
23906+ .query_row(&format!("SELECT count(*) FROM {}", table), [], |r| r.get(0))
23907+ .unwrap_or(0);
23908+
23909+ // Source header: *name, size, row count, migration position.
23910+ let size_part = relation_bytes
23911+ .as_ref()
23912+ .and_then(|m| m.get(name.as_str()))
23913+ .map(|b| format!("{} · ", human_bytes(*b)))
23914+ .unwrap_or_default();
23915+ println!(
23916+ "{} {}",
23917+ st.cyan(&format!("*{}", name)),
23918+ st.dim(&format!(
23919+ "{}{} row{} · migration {}",
23920+ size_part,
23921+ rows,
23922+ if rows == 1 { "" } else { "s" },
23923+ applied
23924+ ))
23925+ );
23926+ // Current descriptor (the db's actual state).
23927+ println!("{}", schema);
23928+
23929+ // The full migration chain, each step marked applied / pending.
23930+ let chain = migration_chain_for(name);
23931+ if !chain.is_empty() {
23932+ for (i, step) in chain.iter().enumerate() {
23933+ let last = i + 1 == chain.len();
23934+ let connector = if last { "└─" } else { "├─" };
23935+ if step.step_idx < *applied {
23936+ println!(
23937+ "{} {} {}",
23938+ st.dim(connector),
23939+ st.green("applied"),
23940+ st.dim(&format!("{} → {}", step.from_schema, step.to_schema))
23941+ );
23942+ } else {
23943+ println!(
23944+ "{} {} {}",
23945+ st.dim(connector),
23946+ st.yellow("pending"),
23947+ format!("{} → {}", step.from_schema, step.to_schema)
23948+ );
23949+ }
23950+ }
23951+ }
23952+ println!();
23953+ }
23954+}
23955+
23956+/// `<program> db` subcommands: with no further argument, launch the TUI
23957+/// explorer; `db info` prints a human-readable report (per-source descriptor,
23958+/// migration chain, row counts); `db dump <out>` snapshots the (live) database
23959+/// to a single binary file (`VACUUM INTO`); `db restore <dump>` replaces the
23960+/// database with a dump file (offline). Returns 1 if handled (caller exits), 0
23961+/// to continue.
23962+#[unsafe(no_mangle)]
2308023963pub extern "C-unwind" fn knot_db_handle(
2308123964 argc: i32,
2308223965 argv: *const *const u8,
@@ -23086,21 +23969,64 @@ pub extern "C-unwind" fn knot_db_handle(
2308623969 if argc < 2 {
2308723970 return 0;
2308823971 }
23089− let arg1 = unsafe {
23090− let ptr = *argv.add(1);
23091− let mut len = 0;
23092− while *ptr.add(len) != 0 {
23093− len += 1;
23094− }
23095− String::from_utf8_lossy(std::slice::from_raw_parts(ptr, len)).to_string()
23096− };
23097−
23972+ let arg1 = unsafe { c_arg(argv, 1) };
2309823973 if arg1 != "db" {
2309923974 return 0;
2310023975 }
23101−
2310223976 let db_path = unsafe { str_from_raw(db_path_ptr, db_path_len) };
23977+ let db = std::path::Path::new(db_path);
23978+ let prog = unsafe { c_arg(argv, 0) };
23979+
23980+ // `./app db dump <out>` / `./app db restore <dump>` — binary snapshot.
23981+ if argc >= 3 {
23982+ match unsafe { c_arg(argv, 2) }.as_str() {
23983+ "dump" => {
23984+ if argc < 4 {
23985+ eprintln!("usage: {} db dump <out>", prog);
23986+ std::process::exit(1);
23987+ }
23988+ let out = unsafe { c_arg(argv, 3) };
23989+ match crate::db_dump::dump_db(db, std::path::Path::new(&out)) {
23990+ Ok(()) => {
23991+ println!("Dumped {} -> {}", db.display(), out);
23992+ return 1;
23993+ }
23994+ Err(e) => {
23995+ eprintln!("db dump: {}", e);
23996+ std::process::exit(1);
23997+ }
23998+ }
23999+ }
24000+ "restore" => {
24001+ if argc < 4 {
24002+ eprintln!("usage: {} db restore <dump>", prog);
24003+ std::process::exit(1);
24004+ }
24005+ let dump = unsafe { c_arg(argv, 3) };
24006+ match crate::db_dump::restore_db(std::path::Path::new(&dump), db) {
24007+ Ok(()) => {
24008+ println!("Restored {} -> {}", dump, db.display());
24009+ return 1;
24010+ }
24011+ Err(e) => {
24012+ eprintln!("db restore: {}", e);
24013+ std::process::exit(1);
24014+ }
24015+ }
24016+ }
24017+ "info" => {
24018+ db_info(db_path);
24019+ return 1;
24020+ }
24021+ other => {
24022+ eprintln!("unknown db subcommand: {}", other);
24023+ eprintln!("usage: {} db [info | dump <out> | restore <dump>]", prog);
24024+ std::process::exit(1);
24025+ }
24026+ }
24027+ }
2310324028
24029+ // `./app db` — the TUI explorer.
2310424030 if let Err(e) = tui::run_db_explorer(db_path) {
2310524031 eprintln!("knot db: {}", e);
2310624032 std::process::exit(1);
@@ -23254,68 +24180,40 @@ fn capitalize(s: &str) -> String {
2325424180/// declarations (`name Text`, `age Int`). The descriptor is the knot record
2325524181/// format: fields separated by 2+ spaces, name and type by a single space.
2325624182/// Handles nested relations (`col Rel {inner}`) and ADT tags.
24183+/// Convert a schema descriptor (`{name Text age Int}`) to knot field
24184+/// declarations (`name Text`, `age (Int 1)`). Fields are split with the
24185+/// single-owner `knot-descriptor` grammar (robust to nested `{..}`/`(..)`),
24186+/// then each field's *type* is re-emitted in descriptor form verbatim — except
24187+/// a bare top-level numeric, which gains the dimensionless unit knot source
24188+/// requires (`Int` → `(Int 1)`). Matches the original hand-rolled splitter's
24189+/// output exactly.
2325724190fn schema_descriptor_to_fields(schema: &str) -> Vec<String> {
23258− // Strip ONE outer brace pair.
2325924191 let t = schema.trim();
23260− let inner = if t.starts_with('{') && t.ends_with('}') {
23261− &t[1..t.len() - 1]
24192+ let parsed = if t.starts_with('{') {
24193+ knot_descriptor::parse(t).ok()
2326224194 } else {
23263− t
24195+ // A bare (braceless) field list, e.g. a ctor payload fragment.
24196+ knot_descriptor::parse(&format!("{{{t}}}")).ok()
24197+ };
24198+ let Some(knot_descriptor::Desc::Record(fields)) = parsed else {
24199+ return Vec::new();
2326424200 };
23265− let inner = inner.trim();
23266− // Split on 2+ spaces (the field separator).
23267− let mut fields = Vec::new();
23268− let mut current = String::new();
23269− let mut depth = 0;
23270− let chars: Vec<char> = inner.chars().collect();
23271− let mut i = 0;
23272− while i < chars.len() {
23273− let c = chars[i];
23274− match c {
23275− '{' => { depth += 1; current.push(c); }
23276− '}' => { depth -= 1; current.push(c); }
23277− '[' => { depth += 1; current.push(c); }
23278− ']' => { depth -= 1; current.push(c); }
23279− ' ' if depth == 0 => {
23280− // 2+ spaces separate fields; a single space is within a field.
23281− let mut j = i;
23282− while j < chars.len() && chars[j] == ' ' {
23283− j += 1;
23284− }
23285− if j - i >= 2 {
23286− // A field boundary.
23287− if !current.trim().is_empty() {
23288− fields.push(current.trim().to_string());
23289− }
23290− current = String::new();
23291− i = j;
23292− continue;
23293− }
23294− current.push(c);
23295− }
23296− _ => current.push(c),
23297− }
23298− i += 1;
23299− }
23300− if !current.trim().is_empty() {
23301− fields.push(current.trim().to_string());
23302− }
23303− // The descriptor stores `Int`/`Float` without a unit. Knot requires one —
23304− // emit `Int 1`/`Float 1` (dimensionless) for a bare numeric type.
2330524201 fields
2330624202 .into_iter()
23307− .map(|f| {
23308− let parts: Vec<&str> = f.splitn(2, ' ').collect();
23309− if parts.len() == 2 {
23310− let (name, ty) = (parts[0], parts[1]);
23311− match ty {
23312− "Int" => format!("{} (Int 1)", name),
23313− "Float" => format!("{} (Float 1)", name),
23314− _ => f,
24203+ .map(|(name, ty)| {
24204+ let ty_str = match ty {
24205+ // Bare numerics need a dimensionless unit in knot source.
24206+ knot_descriptor::Desc::Scalar(knot_descriptor::Scalar::Int) => {
24207+ "(Int 1)".to_string()
2331524208 }
23316− } else {
23317− f
23318− }
24209+ knot_descriptor::Desc::Scalar(knot_descriptor::Scalar::Float) => {
24210+ "(Float 1)".to_string()
24211+ }
24212+ // Everything else round-trips to the descriptor spelling
24213+ // (`(Rel T)`, `(A | B)`, nested records) verbatim.
24214+ other => knot_descriptor::emit(&other),
24215+ };
24216+ format!("{} {}", name, ty_str)
2331924217 })
2332024218 .collect()
2332124219}
@@ -23335,51 +24233,39 @@ fn qualify_adt_ctor(value: &str, type_name: &str) -> String {
2333524233}
2333624234
2333724235/// Convert an ADT schema descriptor (`(Circle{radius Int} | Point)`) to knot
23338−/// constructor declarations (`Circle {radius (Int 1)}`, `Point {}`).
24236+/// constructor declarations (`Circle {radius (Int 1)}`, `Point {}`), using the
24237+/// single-owner `knot-descriptor` grammar.
2333924238fn schema_descriptor_to_adt_ctors(schema: &str) -> Vec<String> {
23340− // Strip the outer parens and split on ` | `.
23341− let inner = schema.trim().trim_start_matches('(').trim_end_matches(')').trim();
23342− let mut ctors = Vec::new();
23343− let mut current = String::new();
23344− let mut depth = 0;
23345− let chars: Vec<char> = inner.chars().collect();
23346− let mut i = 0;
23347− while i < chars.len() {
23348− let c = chars[i];
23349− match c {
23350− '{' => { depth += 1; current.push(c); }
23351− '}' => { depth -= 1; current.push(c); }
23352− '|' if depth == 0 => {
23353− if !current.trim().is_empty() {
23354− ctors.push(adt_ctor_to_knot(current.trim()));
23355− }
23356− current = String::new();
23357− i += 1;
23358− continue;
23359− }
23360− _ => current.push(c),
23361− }
23362− i += 1;
23363− }
23364− if !current.trim().is_empty() {
23365− ctors.push(adt_ctor_to_knot(current.trim()));
23366− }
23367− ctors
24239+ let Ok(knot_descriptor::Desc::Variant(ctors)) = knot_descriptor::parse(schema.trim())
24240+ else {
24241+ return Vec::new();
24242+ };
24243+ ctors.iter().map(|(name, fields)| adt_ctor_to_knot(name, fields)).collect()
2336824244}
2336924245
23370−/// Convert one ADT constructor descriptor (`Circle{radius Int}` or `Point`) to
23371−/// the knot form (`Circle {radius (Int 1)}` or `Point {}`).
23372−fn adt_ctor_to_knot(ctor: &str) -> String {
23373− let ctor = ctor.trim();
23374− if let Some(brace) = ctor.find('{') {
23375− let name = ctor[..brace].trim();
23376− let fields_str = &ctor[brace..]; // `{radius Int}`
23377− let fields = schema_descriptor_to_fields(fields_str);
23378− format!("{} {{{}}}", name, fields.join(" "))
23379− } else {
24246+/// Convert one ADT constructor (name + payload fields) to the knot form
24247+/// (`Circle {radius (Int 1)}` or `Point {}`).
24248+fn adt_ctor_to_knot(name: &str, fields: &[(String, knot_descriptor::Desc)]) -> String {
24249+ if fields.is_empty() {
2338024250 // A nullary constructor.
23381− format!("{} {{}}", ctor)
24251+ return format!("{} {{}}", name);
2338224252 }
24253+ let specs: Vec<String> = fields
24254+ .iter()
24255+ .map(|(n, t)| {
24256+ let ty_str = match t {
24257+ knot_descriptor::Desc::Scalar(knot_descriptor::Scalar::Int) => {
24258+ "(Int 1)".to_string()
24259+ }
24260+ knot_descriptor::Desc::Scalar(knot_descriptor::Scalar::Float) => {
24261+ "(Float 1)".to_string()
24262+ }
24263+ other => knot_descriptor::emit(other),
24264+ };
24265+ format!("{} {}", n, ty_str)
24266+ })
24267+ .collect();
24268+ format!("{} {{{}}}", name, specs.join(" "))
2338324269}
2338424270
2338524271fn generate_openapi(name: &str, table: &RouteTable) -> String {
@@ -24312,6 +25198,7 @@ mod extract_source_tests {
2431225198 env,
2431325199 source: intern_str(source),
2431425200 extract_source: None,
25201+ code: std::ptr::null(),
2431525202 })))
2431625203 }
2431725204 fn func_full(source: &str, env: *mut Value, xsrc: &str) -> *mut Value {
@@ -24320,6 +25207,7 @@ mod extract_source_tests {
2432025207 env,
2432125208 source: intern_str(source),
2432225209 extract_source: Some(intern_str(xsrc)),
25210+ code: std::ptr::null(),
2432325211 })))
2432425212 }
2432525213
@@ -24465,6 +25353,7 @@ mod extract_source_tests {
2446525353 fn_ptr: std::ptr::null(),
2446625354 env,
2446725355 source: intern_str(source),
25356+ code: std::ptr::null(),
2446825357 })))
2446925358 }
2447025359 #[test]
@@ -24514,6 +25403,7 @@ mod show_tests {
2451425403 env,
2451525404 source: intern_str(source),
2451625405 extract_source: None,
25406+ code: std::ptr::null(),
2451725407 })))
2451825408 }
2451925409 fn io(source: &str) -> *mut Value {
@@ -24521,6 +25411,7 @@ mod show_tests {
2452125411 fn_ptr: std::ptr::null(),
2452225412 env: std::ptr::null_mut(),
2452325413 source: intern_str(source),
25414+ code: std::ptr::null(),
2452425415 })))
2452525416 }
2452625417
@@ -24558,6 +25449,7 @@ mod show_tests {
2455825449 env: alloc(Value::Unit),
2455925450 source: intern_str("Nothing"),
2456025451 extract_source: Some(intern_str("Maybe.Nothing {}")),
25452+ code: std::ptr::null(),
2456125453 })));
2456225454 assert_eq!(format_value(f), "Nothing");
2456325455 }
@@ -24684,3 +25576,126 @@ mod show_tests {
2468425576 );
2468525577 }
2468625578}
25579+
25580+#[cfg(test)]
25581+mod compile_containment_tests {
25582+ //! `base.compile` runs in the host's process; a panic in compilation or the
25583+ //! generated snippet must surface as an Err, never unwind across `extern
25584+ //! "C"`. These exercise the shared containment point both `knot_compile_impl`
25585+ //! entry points delegate to.
25586+ use super::*;
25587+
25588+ /// Out-param slots for a contained run, mirroring the FFI caller.
25589+ struct Outs {
25590+ ty: *mut u8,
25591+ ty_len: usize,
25592+ rels: *mut u8,
25593+ rels_len: usize,
25594+ err: *mut u8,
25595+ err_len: usize,
25596+ }
25597+ impl Outs {
25598+ fn new() -> Self {
25599+ Outs {
25600+ ty: std::ptr::null_mut(),
25601+ ty_len: 0,
25602+ rels: std::ptr::null_mut(),
25603+ rels_len: 0,
25604+ err: std::ptr::null_mut(),
25605+ err_len: 0,
25606+ }
25607+ }
25608+ unsafe fn init(&mut self) {
25609+ unsafe {
25610+ compile_out_params_init([
25611+ (&mut self.ty, &mut self.ty_len),
25612+ (&mut self.rels, &mut self.rels_len),
25613+ (&mut self.err, &mut self.err_len),
25614+ ]);
25615+ }
25616+ }
25617+ fn err_string(&self) -> Option<String> {
25618+ if self.err.is_null() {
25619+ return None;
25620+ }
25621+ let bytes = unsafe { std::slice::from_raw_parts(self.err, self.err_len) };
25622+ let s = String::from_utf8_lossy(bytes).into_owned();
25623+ unsafe { libc::free(self.err as *mut c_void) };
25624+ Some(s)
25625+ }
25626+ }
25627+
25628+ #[test]
25629+ fn success_writes_out_params_and_returns_value() {
25630+ let mut o = Outs::new();
25631+ unsafe { o.init() };
25632+ let sentinel = 0xdeadbeefusize as *mut Value;
25633+ let v = unsafe {
25634+ run_compile_contained(
25635+ std::ptr::null_mut(),
25636+ &mut o.ty,
25637+ &mut o.ty_len,
25638+ &mut o.rels,
25639+ &mut o.rels_len,
25640+ &mut o.err,
25641+ &mut o.err_len,
25642+ |_| Some(CompileOutcome {
25643+ value: sentinel,
25644+ ty: Some("Int".to_string()),
25645+ relations: vec!["a\t{..}".to_string()],
25646+ }),
25647+ )
25648+ };
25649+ assert_eq!(v, sentinel);
25650+ assert!(!o.ty.is_null(), "ty written");
25651+ assert!(!o.rels.is_null(), "relations written");
25652+ assert!(o.err.is_null(), "no error on success");
25653+ unsafe {
25654+ libc::free(o.ty as *mut c_void);
25655+ libc::free(o.rels as *mut c_void);
25656+ }
25657+ }
25658+
25659+ #[test]
25660+ fn clean_compile_error_returns_null() {
25661+ let mut o = Outs::new();
25662+ unsafe { o.init() };
25663+ let v = unsafe {
25664+ run_compile_contained(
25665+ std::ptr::null_mut(),
25666+ &mut o.ty,
25667+ &mut o.ty_len,
25668+ &mut o.rels,
25669+ &mut o.rels_len,
25670+ &mut o.err,
25671+ &mut o.err_len,
25672+ |_| None,
25673+ )
25674+ };
25675+ assert!(v.is_null(), "compile error returns null");
25676+ }
25677+
25678+ #[test]
25679+ fn panic_is_contained_and_surfaced_as_err() {
25680+ let mut o = Outs::new();
25681+ unsafe { o.init() };
25682+ let v = unsafe {
25683+ run_compile_contained(
25684+ std::ptr::null_mut(),
25685+ &mut o.ty,
25686+ &mut o.ty_len,
25687+ &mut o.rels,
25688+ &mut o.rels_len,
25689+ &mut o.err,
25690+ &mut o.err_len,
25691+ |_| -> Option<CompileOutcome> { panic!("boom in codegen") },
25692+ )
25693+ };
25694+ assert!(v.is_null(), "panic returns null, does not unwind");
25695+ let err = o.err_string().expect("panic message written to out_err");
25696+ assert!(
25697+ err.contains("compile panicked") && err.contains("boom in codegen"),
25698+ "panic payload surfaced: {err}"
25699+ );
25700+ }
25701+}
crates/knot-runtime/src/tui.rs+21−78
@@ -57,85 +57,28 @@ fn parse_schema_kind(schema: &str) -> SchemaKind {
5757 if t.is_empty() {
5858 return SchemaKind::Unit;
5959 }
60− if t.starts_with('(') && t.ends_with(')') {
61− // ADT: split top-level `|`, each ctor is `Name` or `Name{...}`.
62− let inner = &t[1..t.len() - 1];
63− let mut ctors = Vec::new();
64− for ctor_part in split_toplevel(inner, '|') {
65− let ctor_part = ctor_part.trim();
66− if ctor_part.is_empty() {
67− continue;
68− }
69− if let Some(brace) = ctor_part.find('{') {
70− let name = ctor_part[..brace].trim().to_string();
71− let fields = parse_record_fields(&ctor_part[brace..]);
72− ctors.push(AdtCtor { name, fields });
73− } else {
74− ctors.push(AdtCtor { name: ctor_part.to_string(), fields: Vec::new() });
75− }
76− }
77− SchemaKind::Adt(ctors)
78− } else {
79− SchemaKind::Record(parse_record_fields(t))
80− }
81−}
82−
83−/// Parse `{name Kind name2 Kind2}` into (name, kind) pairs.
84−fn parse_record_fields(s: &str) -> Vec<(String, String)> {
85− let t = s.trim();
86− let inner = match t.strip_prefix('{').and_then(|x| x.strip_suffix('}')) {
87− Some(b) => b,
88− None => return Vec::new(),
89− };
90− let bytes = inner.as_bytes();
91− let mut out = Vec::new();
92− let mut i = 0;
93− while i < bytes.len() {
94− while i < bytes.len() && (bytes[i] as char).is_whitespace() { i += 1; }
95− if i >= bytes.len() { break; }
96− let name_start = i;
97− while i < bytes.len() && ((bytes[i] as char).is_alphanumeric() || bytes[i] == b'_') { i += 1; }
98− let name = &inner[name_start..i];
99− while i < bytes.len() && (bytes[i] as char).is_whitespace() { i += 1; }
100− let kind_start = i;
101− if i < bytes.len() && (bytes[i] == b'{' || bytes[i] == b'(' || bytes[i] == b'[') {
102− let open = bytes[i];
103− let close = match open { b'{' => b'}', b'(' => b')', _ => b']' };
104− let mut depth = 0usize;
105− while i < bytes.len() {
106− if bytes[i] == open { depth += 1; }
107− else if bytes[i] == close { depth -= 1; if depth == 0 { i += 1; break; } }
108− i += 1;
109− }
110− } else {
111− while i < bytes.len() && ((bytes[i] as char).is_alphanumeric() || bytes[i] == b'_') { i += 1; }
112− }
113− let kind = &inner[kind_start..i];
114− if !name.is_empty() {
115− out.push((name.to_string(), kind.to_string()));
116− }
117− }
118− out
119−}
120−
121−/// Split on `sep` at the top nesting level (respecting `{...}`/`(...)`/`[...]`).
122−fn split_toplevel(s: &str, sep: char) -> Vec<&str> {
123− let mut parts = Vec::new();
124− let mut depth = 0usize;
125− let mut start = 0;
126− for (i, c) in s.char_indices() {
127− match c {
128− '{' | '(' | '[' => depth += 1,
129− '}' | ')' | ']' => depth = depth.saturating_sub(1),
130− c if c == sep && depth == 0 => {
131− parts.push(&s[start..i]);
132− start = i + c.len_utf8();
133− }
134− _ => {}
135− }
60+ match knot_descriptor::parse(t) {
61+ Ok(knot_descriptor::Desc::Variant(ctors)) => SchemaKind::Adt(
62+ ctors
63+ .into_iter()
64+ .map(|(name, fields)| AdtCtor {
65+ name,
66+ fields: fields
67+ .into_iter()
68+ .map(|(n, ty)| (n, knot_descriptor::emit(&ty)))
69+ .collect(),
70+ })
71+ .collect(),
72+ ),
73+ Ok(knot_descriptor::Desc::Record(fields)) => SchemaKind::Record(
74+ fields
75+ .into_iter()
76+ .map(|(n, ty)| (n, knot_descriptor::emit(&ty)))
77+ .collect(),
78+ ),
79+ // A scalar/relational element or an unparseable schema: no fields to show.
80+ _ => SchemaKind::Record(Vec::new()),
13681 }
137− parts.push(&s[start..]);
138− parts
13982}
14083
14184// ── Data loading ─────────────────────────────────────────────────
crates/knot/src/ast.rs+15−10
@@ -548,18 +548,23 @@ pub enum TypeKind {
548548 /// grounds against its merged record type.
549549 Callsite,
550550
551− /// `Float M`, `Float (M / S^2)`, `Float u` — a type-level unit
552− /// expression, appearing as the argument of a type application to
553− /// `Int`/`Float`. Carries the compile-time unit algebra (`*`, `/`, `^`).
554− /// A bare `Named(n)` unit is a concrete unit (`M`) when `n` is uppercase
555− /// or a unit variable (`u`) when lowercase.
551+ /// `Float M`, `Float u`, `Float 1` — a type-level unit ATOM appearing as
552+ /// the argument of a type application to `Int`/`Float`. A bare `Named(n)`
553+ /// unit is a concrete unit (`M`) when `n` is uppercase or a unit variable
554+ /// (`u`) when lowercase; `1` is dimensionless; `_` is a unit hole. The
555+ /// unit algebra (`*`, `/`) is expressed by the ordinary-application-shaped
556+ /// `UnitMul`/`UnitDiv` nodes below, so `Int (M / S)` is just an ordinary
557+ /// type application whose argument happens to be unit-shaped — there is no
558+ /// separate unit-annotated type node.
556559 Unit(UnitExpr),
557560
558− /// `Float M` / `Int Usd` / `Float (M / S^2)` — numeric type with unit.
559− /// Kept as a dedicated node (rather than desugared to `App(Named "Float",
560− /// Unit u)`) so inference can recognise the shape without peeling
561− /// application spines. The `base` is `Named "Int"`/`Named "Float"`.
562− UnitAnnotated { base: Box<Type>, unit: UnitExpr },
561+ /// Unit product `a * b`, only ever the (possibly nested) argument of
562+ /// `Int`/`Float`. Parsed by the ordinary type grammar; interpreted as unit
563+ /// multiplication by the inference normalization pass.
564+ UnitMul(Box<Type>, Box<Type>),
565+ /// Unit quotient `a / b`, only ever the (possibly nested) argument of
566+ /// `Int`/`Float`. Interpreted as unit division by the normalization pass.
567+ UnitDiv(Box<Type>, Box<Type>),
563568
564569 /// `T \x -> predicate` — refined type (the predicate is always a lambda).
565570 Refined {
crates/knot/src/format.rs+36−12
@@ -236,11 +236,15 @@ fn render_type_prec(t: &Type, ctx: TyPrec) -> String {
236236 TypeKind::Named(n) => n.clone(),
237237 TypeKind::Var(n) => n.clone(),
238238 TypeKind::App { func, arg } => {
239− let s = format!(
240− "{} {}",
241− render_type_prec(func, TyPrec::App),
242− render_type_atom(arg)
243− );
239+ // A unit-bearing numeric renders its unit argument atom bare, a
240+ // compound unit algebra parenthesized: `Int M`, `Int (M / S)`.
241+ let arg_s = match &arg.node {
242+ TypeKind::UnitMul(..) | TypeKind::UnitDiv(..) => {
243+ format!("({})", render_type_atom(arg))
244+ }
245+ _ => render_type_atom(arg),
246+ };
247+ let s = format!("{} {}", render_type_prec(func, TyPrec::App), arg_s);
244248 if ctx > TyPrec::App {
245249 format!("({})", s)
246250 } else {
@@ -330,14 +334,16 @@ fn render_type_prec(t: &Type, ctx: TyPrec) -> String {
330334 }
331335 TypeKind::Hole => "_".into(),
332336 TypeKind::Callsite => "?".into(),
333− TypeKind::UnitAnnotated { base, unit } => {
334− // `Float M`, `Float (M / S^2)`, `Float u` — space-separated
335− // application. Parenthesize compound units (those with operators)
336− // so the algebraic precedence is explicit.
337− let unit_str = render_unit_type_arg(unit);
338− format!("{} {}", render_type_atom(base), unit_str)
339− }
337+ // A unit-bearing numeric (`Int M`, `Int (M / S)`) is ordinary
338+ // application `App(Int/Float, unit)`; the generic `App` arm renders it.
339+ // The unit arms below render the unit-shaped argument itself.
340340 TypeKind::Unit(u) => render_unit_type_arg(u),
341+ TypeKind::UnitMul(a, b) => {
342+ format!("{} * {}", render_unit_type_operand(a), render_unit_type_operand(b))
343+ }
344+ TypeKind::UnitDiv(a, b) => {
345+ format!("{} / {}", render_unit_type_operand(a), render_unit_type_operand(b))
346+ }
341347 TypeKind::Refined { base, predicate } => {
342348 // `T where \x -> ...` — predicate is always a lambda.
343349 let s = format!(
@@ -444,6 +450,24 @@ fn render_unit_type_arg(u: &UnitExpr) -> String {
444450 }
445451}
446452
453+/// Render a unit-shaped operand of `UnitMul`/`UnitDiv`: a bare atom stays bare,
454+/// a nested product/quotient is parenthesized so the algebra reads unambiguously.
455+fn render_unit_type_operand(t: &Type) -> String {
456+ match &t.node {
457+ TypeKind::Unit(u) => match u {
458+ UnitExpr::Named(n) => n.clone(),
459+ UnitExpr::Dimensionless => "1".into(),
460+ UnitExpr::Hole => "_".into(),
461+ _ => format!("({})", render_unit_expr(u)),
462+ },
463+ other => {
464+ // A nested UnitMul/UnitDiv (or anything unexpected): parenthesize.
465+ let _ = other;
466+ format!("({})", render_type_atom(t))
467+ }
468+ }
469+}
470+
447471// ── Expression precedence ───────────────────────────────────────────
448472
449473/// Mirrors the parser's Pratt binding powers (`parse_expr_bp`): each binary
crates/knot/src/parser.rs+115−66
@@ -799,38 +799,8 @@ impl Parser {
799799// ── Declarations ────────────────────────────────────────────────────
800800
801801impl Parser {
802− /// Parse a unit expression: products, quotients, powers of named units.
803− /// Grammar:
804− /// unit_expr = unit_mul_div
805− /// unit_mul_div = unit_power (('*' | '/') unit_power)*
806− /// unit_power = unit_atom ('^' integer)?
807− /// unit_atom = lower_ident | '1' | '(' unit_expr ')'
808− fn parse_unit_expr(&mut self) -> Option<UnitExpr> {
809− let mut lhs = self.parse_unit_power()?;
810− loop {
811− if self.eat(&TokenKind::Star) {
812− let rhs = self.parse_unit_power()?;
813− lhs = UnitExpr::Mul(Box::new(lhs), Box::new(rhs));
814− } else if self.eat(&TokenKind::Slash) {
815− let rhs = self.parse_unit_power()?;
816− lhs = UnitExpr::Div(Box::new(lhs), Box::new(rhs));
817− } else if self.at(&TokenKind::Caret) {
818− // `^` power was removed: a unit power is written with `*`
819− // (`M ^ 2` → `M * M`), which is also the form arithmetic
820− // produces. Point the user at it.
821− self.error("`^` unit power is gone — write the product instead: `M ^ 2` is `M * M`");
822− return None;
823− } else {
824− break;
825− }
826− }
827− Some(lhs)
828− }
829−
830− fn parse_unit_power(&mut self) -> Option<UnitExpr> {
831− self.parse_unit_atom()
832− }
833−
802+ /// Parse one unit atom: a named unit (`M`, `u`), the dimensionless `1`,
803+ /// or a hole `_`. Used by `parse_unit_atom_ty` to build a `Type::Unit`.
834804 fn parse_unit_atom(&mut self) -> Option<UnitExpr> {
835805 match self.peek() {
836806 TokenKind::Upper(_) => {
@@ -855,13 +825,6 @@ impl Parser {
855825 self.advance();
856826 Some(UnitExpr::Hole)
857827 }
858− TokenKind::LParen => {
859− self.advance();
860− let inner = self.parse_unit_expr()?;
861− self.expect(&TokenKind::RParen, "expected ')' in unit expression")
862− .ok()?;
863− Some(inner)
864− }
865828 _ => {
866829 self.error("expected unit name, '1', or '(' in unit expression");
867830 None
@@ -882,22 +845,78 @@ impl Parser {
882845 }
883846 }
884847
885− /// Parse a unit argument in type position: `M`, `u`, `1`, or
886− /// `(M / S^2)`. A bare identifier is a single unit atom; a parenthesized
887− /// form allows the full unit algebra (`* / ^`).
888− fn parse_unit_type_arg(&mut self) -> Option<UnitExpr> {
848+ /// Parse the unit argument of `Int`/`Float` as an ordinary type-shaped tree:
849+ /// a bare atom (`Int M`, `Float u`, `Int 1`) or a parenthesized algebra
850+ /// (`Int (M / S)`). The argument is unit-shaped — `Unit` atoms combined by
851+ /// `UnitMul`/`UnitDiv` — but carried in the ordinary `Type` tree so the
852+ /// argument of `Int`/`Float` is just a regular type-application argument.
853+ /// The inference normalization pass interprets it as a unit.
854+ fn parse_unit_shaped_arg(&mut self) -> Option<Type> {
889855 match self.peek() {
890856 TokenKind::LParen => {
891857 self.advance();
892− let inner = self.parse_unit_expr()?;
858+ let inner = self.parse_unit_algebra()?;
893859 self.expect(&TokenKind::RParen, "expected ')' in unit argument")
894860 .ok()?;
895861 Some(inner)
896862 }
897− _ => self.parse_unit_atom(),
863+ _ => self.parse_unit_atom_ty(),
864+ }
865+ }
866+
867+ /// Parse a unit algebra over `Type` nodes: `*`/`/` products and quotients of
868+ /// unit atoms, left-associative. Scoped to the parenthesized argument of
869+ /// `Int`/`Float`; nowhere else in the type grammar.
870+ fn parse_unit_algebra(&mut self) -> Option<Type> {
871+ let mut lhs = self.parse_unit_algebra_atom()?;
872+ loop {
873+ if self.at(&TokenKind::Star) {
874+ self.advance();
875+ let rhs = self.parse_unit_algebra_atom()?;
876+ let span = Span::new(lhs.span.start, rhs.span.end);
877+ lhs = Spanned::new(
878+ TypeKind::UnitMul(Box::new(lhs), Box::new(rhs)),
879+ span,
880+ );
881+ } else if self.at(&TokenKind::Slash) {
882+ self.advance();
883+ let rhs = self.parse_unit_algebra_atom()?;
884+ let span = Span::new(lhs.span.start, rhs.span.end);
885+ lhs = Spanned::new(
886+ TypeKind::UnitDiv(Box::new(lhs), Box::new(rhs)),
887+ span,
888+ );
889+ } else if self.at(&TokenKind::Caret) {
890+ self.error("`^` unit power is gone — write the product instead: `M ^ 2` is `M * M`");
891+ return None;
892+ } else {
893+ break;
894+ }
895+ }
896+ Some(lhs)
897+ }
898+
899+ fn parse_unit_algebra_atom(&mut self) -> Option<Type> {
900+ match self.peek() {
901+ TokenKind::LParen => {
902+ self.advance();
903+ let inner = self.parse_unit_algebra()?;
904+ self.expect(&TokenKind::RParen, "expected ')' in unit expression")
905+ .ok()?;
906+ Some(inner)
907+ }
908+ _ => self.parse_unit_atom_ty(),
898909 }
899910 }
900911
912+ /// Parse one unit atom as a `Type`: a named unit (`M`, `u`), the
913+ /// dimensionless `1`, or a hole `_`.
914+ fn parse_unit_atom_ty(&mut self) -> Option<Type> {
915+ let unit = self.parse_unit_atom()?;
916+ let span = self.prev_span();
917+ Some(Spanned::new(TypeKind::Unit(unit), span))
918+ }
919+
901920 // ── subset constraint ────────────────────────────────────────────
902921
903922 // Parse the rest of a subset constraint after `*name` has been consumed.
@@ -2498,6 +2517,17 @@ impl Parser {
24982517 unreachable!()
24992518 };
25002519 let bare = n.trim_start_matches('#').to_string();
2520+ // A `#name` may be declared in *different* scopes (they
2521+ // resolve to the same value), but never twice in one
2522+ // record.
2523+ if fields.iter().any(|f| {
2524+ matches!(&f.value.node, ExprKind::ArgDecl { name, .. } if *name == bare)
2525+ }) {
2526+ self.error_at(
2527+ t.span,
2528+ format!("argument `#{bare}` is declared more than once in this scope"),
2529+ );
2530+ }
25012531 self.skip_newlines();
25022532 let default = if self.at(&TokenKind::Underscore) {
25032533 self.advance();
@@ -2673,6 +2703,16 @@ impl Parser {
26732703 let hspan = self.span();
26742704 self.advance();
26752705 let bare = hname.trim_start_matches('#').to_string();
2706+ // A `#name` may be declared in *different* scopes (they resolve
2707+ // to the same value), but never twice in one record.
2708+ if fields.iter().any(|f| {
2709+ matches!(&f.value.node, ExprKind::ArgDecl { name, .. } if *name == bare)
2710+ }) {
2711+ self.error_at(
2712+ hspan,
2713+ format!("argument `#{bare}` is declared more than once in this scope"),
2714+ );
2715+ }
26762716 self.skip_newlines();
26772717 let default = if self.at(&TokenKind::Underscore) {
26782718 self.advance();
@@ -4100,9 +4140,15 @@ impl Parser {
41004140 Some(arg) => arg,
41014141 None => fail!(),
41024142 };
4103− if !arg_parenthesized
4104− && matches!(arg.node, TypeKind::UnitAnnotated { .. })
4105− {
4143+ // A unit-carrying numeric type (`Int M`) now arrives as ordinary
4144+ // application `App(Named Int, Unit _)`. Unparenthesized in
4145+ // argument position it would mis-nest (`Maybe Int M` reads `M`
4146+ // as Maybe's second argument), so require parens.
4147+ let is_unit_numeric = matches!(&arg.node,
4148+ TypeKind::App { func, .. }
4149+ if matches!(&func.node, TypeKind::Named(n) if n == "Int" || n == "Float")
4150+ );
4151+ if !arg_parenthesized && is_unit_numeric {
41064152 self.error(
41074153 "parenthesize a unit-carrying type argument: write `Maybe (Int 1)`, not `Maybe Int 1`",
41084154 );
@@ -4235,25 +4281,28 @@ impl Parser {
42354281 },
42364282 span,
42374283 ))
4238− } else if (name == "Float" || name == "Int") && self.can_start_unit_type_arg() {
4239− // `Float M`, `Float u`, `Float (M / S)` — the unit is a
4240− // regular type-argument position parsed as a unit
4241− // expression. A bare Upper/Lower identifier is a unit
4242− // (`M`, `u`); a parenthesized form carries the algebraic
4243− // operators `* /`. A `(` could also start a parenthesized
4244− // type (`Float (Int -> Text)` is application, not a unit),
4245− // so save/restore on failure.
4246− let saved = self.save();
4247− let diag_count = self.diagnostics.len();
4248− let unit = self.parse_unit_type_arg();
4249− if let Some(unit) = unit {
4250− let span = Span::new(tok.span.start, self.prev_span().end);
4251− let base = Box::new(Spanned::new(TypeKind::Named(name), tok.span));
4252− return Some(Spanned::new(TypeKind::UnitAnnotated { base, unit }, span));
4284+ } else if name == "Int" || name == "Float" {
4285+ // `Int`/`Float` may take a unit argument (`Int M`, `Int 1`,
4286+ // `Int (M / S)`), parsed by the application loop in
4287+ // `parse_type_app`. Route back through it so the unit is
4288+ // consumed even when this atom is reached directly (e.g. as
4289+ // `IO`'s argument: `IO Int 1`). We already consumed the head
4290+ // token, so reconstruct the application manually: parse the
4291+ // unit-shaped argument if present and wrap in `App`.
4292+ let base = Spanned::new(TypeKind::Named(name), tok.span);
4293+ if self.can_start_unit_type_arg() {
4294+ if let Some(arg) = self.parse_unit_shaped_arg() {
4295+ let span = Span::new(tok.span.start, arg.span.end);
4296+ return Some(Spanned::new(
4297+ TypeKind::App {
4298+ func: Box::new(base),
4299+ arg: Box::new(arg),
4300+ },
4301+ span,
4302+ ));
4303+ }
42534304 }
4254− self.diagnostics.truncate(diag_count);
4255− self.restore(saved);
4256− Some(Spanned::new(TypeKind::Named(name), tok.span))
4305+ Some(base)
42574306 } else {
42584307 Some(Spanned::new(TypeKind::Named(name), tok.span))
42594308 }