diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 3ba870aa..af76ed87 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -24,6 +24,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "own-bridge" +version = "0.1.0" +dependencies = [ + "own-ir", + "own-lowered", + "serde_json", +] + [[package]] name = "own-cfg" version = "0.1.0" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 56089b5b..a943935f 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -8,7 +8,7 @@ [workspace] resolver = "2" -members = ["crates/own-ir", "crates/own-syntax", "crates/own-cfg", "crates/own-diagnostics", "crates/own-analysis", "crates/own-lowered"] +members = ["crates/own-ir", "crates/own-syntax", "crates/own-cfg", "crates/own-diagnostics", "crates/own-analysis", "crates/own-lowered", "crates/own-bridge"] [workspace.package] edition = "2021" diff --git a/rust/crates/own-bridge/Cargo.toml b/rust/crates/own-bridge/Cargo.toml new file mode 100644 index 00000000..0406ed1a --- /dev/null +++ b/rust/crates/own-bridge/Cargo.toml @@ -0,0 +1,20 @@ +# The OwnIR -> Layer 2 lowering (P-022 #259 slice 3): a pure transformation +# crate. Production API is `OwnIr -> Result` — +# no filesystem, no CLI, no diagnostics, no analysis side effects; fixture +# I/O lives only in the integration tests. +[package] +name = "own-bridge" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true +description = "OwnIR facts -> normalized Layer 2 lowering (Python to_module parity)" + +[dependencies] +own-ir = { path = "../own-ir" } +own-lowered = { path = "../own-lowered" } +serde_json.workspace = true + +[lints] +workspace = true diff --git a/rust/crates/own-bridge/src/lib.rs b/rust/crates/own-bridge/src/lib.rs new file mode 100644 index 00000000..36a60a5b --- /dev/null +++ b/rust/crates/own-bridge/src/lib.rs @@ -0,0 +1,52 @@ +//! `own-bridge` — the `OwnIR` facts → Layer 2 lowering (P-022 #259 slice 3). +//! +//! The Rust port of `ownlang/ownir.py::to_module` **restricted to the behavior +//! the shared Layer 2 fixtures exercise**: routing R1–R6, global `sub_`/`cap_` +//! and `parg_`/`loc_` handle minting, capture/DI lifetime regions, flow +//! lowering with the local map and kill-on-rebind, branch-local hoisting with +//! its negative gates, `alias_join`, unmapped references, call lowering, the +//! `$consume`/`$borrow`/`$borrow_mut` channels, the precise-overload channel +//! vs the merged-may kill site, in-branch untrack vs top-level kill site, +//! fresh-result minting, and the fail-loud flow-op vocabulary. +//! +//! **Pure transformation**: [`lower`] maps a typed [`own_ir::OwnIr`] document +//! to an [`own_lowered::LoweredDocument`] (or a [`BridgeError`] whose message +//! text is part of the parity surface — Python projects it as the `Rejected` +//! form). No filesystem, no CLI, no diagnostics, no analysis. The tolerant +//! door, `OwnIR` validation parity, MOS contract *changes*, and analysis +//! wiring are all out of scope (#294 stays open; the `tolerant_unknown_kind` +//! fixture stays Python-only). +//! +//! The oracle is byte-exact: for every `rust_replay: true` manifest case, +//! `facts → OwnIr::from_json → lower → own_lowered::to_canonical_json` must +//! equal the committed Python golden (`tests/replay.rs`). The goldens are +//! expected output ONLY — never an input to construction. + +mod lower; +mod mos; + +use own_ir::OwnIr; +use own_lowered::LoweredDocument; + +/// A lowering rejection — the Rust twin of Python's `OwnIRError` from +/// `to_module`. The message TEXT is part of the Layer 2 parity surface +/// (a fail-loud golden pins it byte-for-byte). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BridgeError(pub String); + +impl std::fmt::Display for BridgeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for BridgeError {} + +/// Lower one `OwnIR` facts document into the normalized Layer 2 document. +/// +/// # Errors +/// [`BridgeError`] on vocabulary skew the reference bridge fails loud on +/// (e.g. an unknown flow op); the message text matches Python's `OwnIRError`. +pub fn lower(facts: &OwnIr) -> Result { + lower::lower(facts) +} diff --git a/rust/crates/own-bridge/src/lower.rs b/rust/crates/own-bridge/src/lower.rs new file mode 100644 index 00000000..23a94992 --- /dev/null +++ b/rust/crates/own-bridge/src/lower.rs @@ -0,0 +1,1877 @@ +//! The `ownlang/ownir.py::to_module` port — `OwnIR` facts → the normalized +//! Layer 2 document, restricted to the behavior the shared fixtures exercise. +//! +//! The walk is deliberately DICT-SHAPED: the typed [`OwnIr`] document is +//! re-serialized to a JSON value once and lowered by the same key-by-key +//! logic as the Python reference (own-ir's round-trip preservation is a +//! pinned property, so the value equals the original facts). That keeps +//! every membership/default/truthiness decision textually comparable to +//! `to_module` instead of re-deriving it through a second type system. +//! +//! One deliberate divergence, guarded loud: a PRESENT-but-unknown resource +//! kind (Python's tolerant door falls back to `Subscription`) is a +//! [`BridgeError`] here — the tolerant-door contract is an open decision +//! (#294, the `tolerant_unknown_kind` fixture stays Python-only), and this +//! crate refuses to guess either way. + +// The lowering mirrors `to_module` branch-for-branch; splitting it further +// (or moving each walk's helper away from its single caller) would trade +// lint scores for a port that no longer reads against the reference. +// Invariant-backed map reads use expect() (never bare indexing). +#![allow( + clippy::too_many_lines, + clippy::expect_used, + clippy::items_after_statements, + clippy::redundant_pub_crate +)] + +use crate::mos::{ + self, MethodSkeleton, MethodSummary, Mos, ParamSkeleton, PathAction, ReturnSkeleton, Transfer, +}; +use crate::BridgeError; +use own_ir::OwnIr; +use own_lowered::{ + Extern, ExternParam, Function, HandleEntry, Lifetime, LoweredDocument, Maybe, Param, Resource, + ResourceMember, Stmt, TypeShape, LOWERED_VERSION, +}; +use serde_json::{Map, Value}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; + +type Obj = Map; + +// --- Python-semantics helpers ------------------------------------------------ + +/// Python truthiness over a JSON value (absent handled by the caller). +fn py_truthy(v: Option<&Value>) -> bool { + match v { + None | Some(Value::Null) => false, + Some(Value::Bool(b)) => *b, + Some(Value::Number(n)) => n.as_f64().is_some_and(|f| f != 0.0), + Some(Value::String(s)) => !s.is_empty(), + Some(Value::Array(a)) => !a.is_empty(), + Some(Value::Object(o)) => !o.is_empty(), + } +} + +/// Python `str(v)` over the JSON values the facts carry. Containers are not +/// reproduced (Python would repr them); no fixture nor real extractor puts a +/// container where a scalar is read. +fn py_str(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Null => "None".to_owned(), + Value::Bool(true) => "True".to_owned(), + Value::Bool(false) => "False".to_owned(), + other => other.to_string(), + } +} + +/// Python `{x!r}` for the fail-loud message; only strings occur in practice. +fn py_repr(v: Option<&Value>) -> String { + match v { + None | Some(Value::Null) => "None".to_owned(), + Some(Value::String(s)) => format!("'{s}'"), + Some(Value::Bool(true)) => "True".to_owned(), + Some(Value::Bool(false)) => "False".to_owned(), + Some(other) => other.to_string(), + } +} + +/// `_as_int`: a non-throwing int coercion (a bool is NOT an int here — serde +/// keeps them distinct, matching Python's explicit bool check). +fn as_int(v: Option<&Value>) -> i64 { + v.and_then(Value::as_i64).unwrap_or(0) +} + +/// `n.get(key)` where a present non-list / absent key reads as empty. +fn as_list(v: Option<&Value>) -> &[Value] { + v.and_then(Value::as_array).map_or(&[], Vec::as_slice) +} + +fn get_str<'a>(obj: &'a Obj, key: &str) -> Option<&'a str> { + obj.get(key).and_then(Value::as_str) +} + +/// `str(obj.get(key, default))` — Python stringifies a PRESENT value of any +/// type; only an absent key takes the default. +fn str_or(obj: &Obj, key: &str, default: impl Into) -> String { + obj.get(key).map_or_else(|| default.into(), py_str) +} + +// --- frozen vocabulary ------------------------------------------------------- + +const SUBSCRIBER_REGION: &str = "Subscriber"; +const SINK_EXTERN_NAMES: [&str; 3] = ["$consume", "$borrow", "$borrow_mut"]; + +/// `_RESOURCES`: kind → the own resource type to acquire. +fn resource_type(rkind: &str) -> Option<&'static str> { + match rkind { + "subscription" | "subscribe" => Some("Subscription"), + "timer" => Some("Timer"), + "disposable" | "local-disposable" => Some("Disposable"), + "pool" => Some("PooledBuffer"), + _ => None, + } +} + +/// `_CAPTURE_SOURCE_REGIONS`: only provably-longer sources are mapped. +fn capture_source_region(source: &str) -> Option<&'static str> { + (source == "static").then_some("Process") +} + +/// `_DI_REGION`. +fn di_region(life: &str) -> Option<&'static str> { + match life { + "singleton" => Some("Process"), + "scoped" => Some("scoped"), + "transient" => Some("transient"), + _ => None, + } +} + +fn prelude_resources() -> Vec { + let res = |name: &str, kind: &str, acq: &str, rel: &str| Resource { + name: name.to_owned(), + kind: Some(kind.to_owned()), + members: vec![ + ResourceMember { + role: "acquire".to_owned(), + name: acq.to_owned(), + }, + ResourceMember { + role: "release".to_owned(), + name: rel.to_owned(), + }, + ], + }; + vec![ + res("Subscription", "subscription token", "Subscribe", "Dispose"), + res("Timer", "timer", "Start", "Stop"), + res("Disposable", "disposable field", "New", "Dispose"), + res("PooledBuffer", "pooled buffer", "Rent", "Return"), + ] +} + +fn sink_externs() -> Vec { + let ext = |name: &str, effect: &str| Extern { + name: name.to_owned(), + params: vec![ExternParam { + effect: effect.to_owned(), + type_name: "Disposable".to_owned(), + }], + }; + vec![ + ext("$consume", "consume"), + ext("$borrow", "borrow"), + ext("$borrow_mut", "borrow_mut"), + ] +} + +fn capture_lifetimes() -> Vec { + let lt = |name: &str, longer: Option<&str>| Lifetime { + name: name.to_owned(), + longer: longer.map(str::to_owned), + }; + vec![ + lt("Process", None), + lt("scoped", Some("Process")), + lt("transient", Some("scoped")), + lt(SUBSCRIBER_REGION, Some("Process")), + ] +} + +/// The curated Tier B BCL fresh-factory table (`_BCL_FRESH_BY_NS`), accepted +/// as the bare `Type.Method` or its exact fully-qualified identity. +const BCL_FRESH: [(&str, &[&str]); 4] = [ + ( + "System.IO", + &[ + "File.OpenRead", + "File.OpenText", + "File.OpenWrite", + "File.Open", + "File.Create", + "File.CreateText", + "File.AppendText", + "File.OpenHandle", + ], + ), + ( + "System.Security.Cryptography", + &[ + "SHA1.Create", + "SHA256.Create", + "SHA384.Create", + "SHA512.Create", + "MD5.Create", + "Aes.Create", + "RSA.Create", + "ECDsa.Create", + ], + ), + ("System.Xml", &["XmlReader.Create", "XmlWriter.Create"]), + ("System.Text.Json", &["JsonDocument.Parse"]), +]; + +fn is_bcl_fresh_factory(callee: &str) -> bool { + if callee.is_empty() { + return false; + } + let name = canonical(callee); + BCL_FRESH.iter().any(|(ns, entries)| { + entries.iter().any(|e| { + *e == name || name.strip_prefix(ns).and_then(|r| r.strip_prefix('.')) == Some(*e) + }) + }) +} + +// --- callee identity / MOS resolution ---------------------------------------- + +/// `_canonical_callee_name`: the `global::`-stripped identity. +fn canonical(name: &str) -> &str { + name.strip_prefix("global::").unwrap_or(name) +} + +/// `_sig_key`: the per-overload summary key. +fn sig_key(name: &str, sig: &str) -> String { + format!("{name}({sig})") +} + +/// `_call_sig`: the optional canonical parameter-type list of a record/op. +fn call_sig(node: &Obj) -> Option<&str> { + get_str(node, "sig") +} + +/// `_mos_lookup`: per-overload key first (raw and canonical), then the exact +/// name, then its canonical form — the name-merged fallback. +fn mos_lookup<'m>(mos: &'m Mos, callee: &str, sig: Option<&str>) -> Option<&'m MethodSummary> { + if callee.is_empty() { + return None; + } + let identity = canonical(callee); + if let Some(sig) = sig { + if let Some(s) = mos.get(&sig_key(callee, sig)) { + return Some(s); + } + if identity != callee { + if let Some(s) = mos.get(&sig_key(identity, sig)) { + return Some(s); + } + } + } + if let Some(s) = mos.get(callee) { + return Some(s); + } + if identity != callee { + return mos.get(identity); + } + None +} + +/// `_callee_returns_fresh`: Tier A (a first-party summary) is authoritative; +/// only a summary-less, non-first-party callee falls back to the BCL table. +fn callee_returns_fresh( + callee: &str, + mos: &Mos, + first_party: &HashSet, + sig: Option<&str>, +) -> bool { + if callee.is_empty() { + return false; + } + let identity = canonical(callee); + if let Some(summ) = mos_lookup(mos, callee, sig) { + return summ.returns == "fresh"; + } + if first_party.contains(identity) { + return false; + } + is_bcl_fresh_factory(callee) +} + +// --- flow-body walks (the `_*` helpers of ownir.py) -------------------------- + +/// `_released_vars`. +fn released_vars(nodes: &[Value]) -> HashSet { + let mut out = HashSet::new(); + fn walk(nodes: &[Value], out: &mut HashSet) { + for n in nodes { + let Some(n) = n.as_object() else { continue }; + match get_str(n, "op") { + Some("release") => { + if let Some(v) = get_str(n, "var") { + out.insert(v.to_owned()); + } + } + Some("if") => { + walk(as_list(n.get("then")), out); + walk(as_list(n.get("else")), out); + } + Some("while") => walk(as_list(n.get("body")), out), + _ => {} + } + } + } + walk(nodes, &mut out); + out +} + +/// `_returns_value`. +fn returns_value(nodes: &[Value]) -> bool { + nodes + .iter() + .filter_map(Value::as_object) + .any(|n| match get_str(n, "op") { + Some("return") => n.get("var").is_some_and(|v| !v.is_null()), + Some("if") => { + returns_value(as_list(n.get("then"))) || returns_value(as_list(n.get("else"))) + } + Some("while") => returns_value(as_list(n.get("body"))), + _ => false, + }) +} + +/// `_collect_vars`. +fn collect_vars(nodes: &[Value], op_kind: &str, field: &str) -> HashSet { + let mut out = HashSet::new(); + fn walk(nodes: &[Value], op_kind: &str, field: &str, out: &mut HashSet) { + for n in nodes { + let Some(n) = n.as_object() else { continue }; + match get_str(n, "op") { + Some(op) if op == op_kind => { + if let Some(v) = get_str(n, field) { + out.insert(v.to_owned()); + } + } + Some("if") => { + walk(as_list(n.get("then")), op_kind, field, out); + walk(as_list(n.get("else")), op_kind, field, out); + } + Some("while") => walk(as_list(n.get("body")), op_kind, field, out), + _ => {} + } + } + } + walk(nodes, op_kind, field, &mut out); + out +} + +/// `_has_bare_return`. +fn has_bare_return(nodes: &[Value]) -> bool { + nodes + .iter() + .filter_map(Value::as_object) + .any(|n| match get_str(n, "op") { + // MSRV 1.74: `Option::is_none_or` is not available yet. + Some("return") => !n.get("var").is_some_and(|v| !v.is_null()), + Some("if") => { + has_bare_return(as_list(n.get("then"))) || has_bare_return(as_list(n.get("else"))) + } + Some("while") => has_bare_return(as_list(n.get("body"))), + _ => false, + }) +} + +type CallOrigin = Option<(String, Option)>; + +/// `_call_result_callees`: result local → `(callee, sig)`; `None` = ambiguous. +fn call_result_callees(nodes: &[Value]) -> HashMap { + let mut out: HashMap = HashMap::new(); + fn visit(nodes: &[Value], out: &mut HashMap) { + for n in nodes { + let Some(n) = n.as_object() else { continue }; + match get_str(n, "op") { + Some("call") => { + let (Some(res), Some(callee)) = (get_str(n, "result"), get_str(n, "callee")) + else { + continue; + }; + if callee.is_empty() { + continue; + } + let entry = (callee.to_owned(), call_sig(n).map(str::to_owned)); + match out.get(res) { + None => { + out.insert(res.to_owned(), Some(entry)); + } + Some(None) => {} + Some(Some((prev_callee, prev_sig))) => { + if *prev_callee != entry.0 { + out.insert(res.to_owned(), None); + } else if *prev_sig != entry.1 { + out.insert(res.to_owned(), Some((entry.0, None))); + } + } + } + } + Some("if") => { + visit(as_list(n.get("then")), out); + visit(as_list(n.get("else")), out); + } + Some("while") => visit(as_list(n.get("body")), out), + _ => {} + } + } + } + visit(nodes, &mut out); + out +} + +/// `_param_signals`: (released, handed-to-a-call, used) on any path. +fn param_signals(pname: &str, nodes: &[Value]) -> (bool, bool, bool) { + let (mut rel, mut passed, mut used) = (false, false, false); + for n in nodes { + let Some(n) = n.as_object() else { continue }; + match get_str(n, "op") { + Some("release") if py_str(n.get("var").unwrap_or(&Value::Null)) == pname => rel = true, + Some("call") => { + if as_list(n.get("args")).iter().any(|a| py_str(a) == pname) { + passed = true; + } + } + Some("use") if py_str(n.get("var").unwrap_or(&Value::Null)) == pname => used = true, + Some("if") => { + for sub in [as_list(n.get("then")), as_list(n.get("else"))] { + let (sr, sp, su) = param_signals(pname, sub); + rel |= sr; + passed |= sp; + used |= su; + } + } + Some("while") => { + let (sr, sp, su) = param_signals(pname, as_list(n.get("body"))); + rel |= sr; + passed |= sp; + used |= su; + } + _ => {} + } + } + (rel, passed, used) +} + +/// `_walk_release`: (`rel_out` 0/1/2, `falls_through`, `exits_ok`). +fn walk_release(pname: &str, nodes: &[Value], rel_in: u8) -> (u8, bool, bool) { + let mut rel = rel_in; + let mut exits_ok = true; + for n in nodes { + let Some(n) = n.as_object() else { continue }; + match get_str(n, "op") { + Some("release") if py_str(n.get("var").unwrap_or(&Value::Null)) == pname => rel = 2, + Some("return") => return (rel, false, exits_ok && rel == 2), + Some("if") => { + let (rt, lt, okt) = walk_release(pname, as_list(n.get("then")), rel); + let (re, le, oke) = walk_release(pname, as_list(n.get("else")), rel); + exits_ok = exits_ok && okt && oke; + if lt && le { + rel = if rt == re { rt } else { 1 }; + } else if lt || le { + rel = if lt { rt } else { re }; + } else { + return (rel, false, exits_ok); // both branches returned + } + } + Some("while") => { + let (rb, lb, okb) = walk_release(pname, as_list(n.get("body")), rel); + exits_ok = exits_ok && okb; + if lb && rb != rel { + rel = 1; + } + } + _ => {} + } + } + (rel, true, exits_ok) +} + +/// `_definite_release`: released on EVERY normal-return path. +fn definite_release(pname: &str, nodes: &[Value]) -> bool { + let (rel, falls_through, exits_ok) = walk_release(pname, nodes, 0); + exits_ok && (!falls_through || rel == 2) +} + +/// `_forward_targets`: every `(callee, sig, arg_index)` a call hands `pname` to. +fn forward_targets( + pname: &str, + nodes: &[Value], + recurse: bool, +) -> Vec<(String, Option, i64)> { + let mut out = Vec::new(); + for n in nodes { + let Some(n) = n.as_object() else { continue }; + match get_str(n, "op") { + Some("call") => { + let callee = str_or(n, "callee", ""); + if callee.is_empty() { + continue; + } + for (j, a) in as_list(n.get("args")).iter().enumerate() { + if py_str(a) == pname { + out.push(( + callee.clone(), + call_sig(n).map(str::to_owned), + i64::try_from(j).unwrap_or(i64::MAX), + )); + } + } + } + Some("if") if recurse => { + out.extend(forward_targets(pname, as_list(n.get("then")), true)); + out.extend(forward_targets(pname, as_list(n.get("else")), true)); + } + Some("while") if recurse => { + out.extend(forward_targets(pname, as_list(n.get("body")), true)); + } + _ => {} + } + } + out +} + +/// `_contains_return`. +fn contains_return(n: &Obj) -> bool { + match get_str(n, "op") { + Some("return") => true, + Some("if") => [as_list(n.get("then")), as_list(n.get("else"))] + .into_iter() + .any(|s| s.iter().filter_map(Value::as_object).any(contains_return)), + Some("while") => as_list(n.get("body")) + .iter() + .filter_map(Value::as_object) + .any(contains_return), + _ => false, + } +} + +/// `_early_return_before_forward`. +fn early_return_before_forward(pname: &str, nodes: &[Value]) -> bool { + for n in nodes { + let Some(n) = n.as_object() else { continue }; + if get_str(n, "op") == Some("call") + && as_list(n.get("args")).iter().any(|a| py_str(a) == pname) + { + return false; // reached the forward first + } + if contains_return(n) { + return true; + } + } + false +} + +/// `_infer_return_skeleton` (P-005 D5.2, precision-first). +fn infer_return_skeleton( + nodes: &[Value], + param_names: &HashSet, + first_party: &HashSet, + call_key: &dyn Fn(&str, Option<&str>) -> String, +) -> ReturnSkeleton { + let returned = collect_vars(nodes, "return", "var"); + if returned.is_empty() { + return ReturnSkeleton::None; + } + if has_bare_return(nodes) { + return ReturnSkeleton::None; + } + let acquired = collect_vars(nodes, "acquire", "var"); + let call_results = call_result_callees(nodes); + if returned + .iter() + .all(|v| acquired.contains(v) && !param_names.contains(v) && !call_results.contains_key(v)) + { + return ReturnSkeleton::Fresh; + } + if returned.len() == 1 { + let v = returned.iter().next().expect("len == 1"); + if let Some(Some((callee, csig))) = call_results.get(v) { + if !param_names.contains(v) && !acquired.contains(v) { + if !first_party.contains(canonical(callee)) && is_bcl_fresh_factory(callee) { + return ReturnSkeleton::Fresh; + } + return ReturnSkeleton::Forward { + callee: call_key(callee, csig.as_deref()), + }; + } + } + } + ReturnSkeleton::None +} + +/// `_infer_param_effect`: the bounded interprocedural contract inference. +fn infer_param_effect( + pname: &str, + nodes: &[Value], + forward_transfer: Option, +) -> Option<&'static str> { + let (rel, passed, used) = param_signals(pname, nodes); + if rel { + return definite_release(pname, nodes).then_some("consume"); + } + if passed { + return match forward_transfer { + Some(Transfer::Must) => Some("consume"), + Some(Transfer::No) => Some("borrow"), + _ => None, // may / unknown / unresolved -> plain (precision-first) + }; + } + used.then_some("borrow") +} + +// --- skeleton building (`_build_skeletons` + `_merge_skeletons`) ------------- + +/// `_merge_returns`: only a kind ALL overloads agree on survives. +fn merge_returns(rets: &[&ReturnSkeleton]) -> ReturnSkeleton { + if rets.iter().all(|r| matches!(r, ReturnSkeleton::Fresh)) { + return ReturnSkeleton::Fresh; + } + if rets.iter().all(|r| matches!(r, ReturnSkeleton::None)) { + return ReturnSkeleton::None; + } + ReturnSkeleton::Unknown // mixed / forward -> fails closed +} + +/// `_merge_skeletons`: collapse same-key overloads into ONE conservative +/// summary at (key, parameter-index) granularity. +fn merge_skeletons(key: &str, group: &[MethodSkeleton]) -> MethodSkeleton { + if let [single] = group { + let mut sk = single.clone(); + key.clone_into(&mut sk.key); + return sk; + } + let mut by_index: BTreeMap> = BTreeMap::new(); + for sk in group { + for p in &sk.params { + let paths = if p.paths.is_empty() { + vec![PathAction::Borrow] // a kept index joins in as a borrow + } else { + p.paths.clone() + }; + by_index.entry(p.index).or_default().extend(paths); + } + } + let params = by_index + .into_iter() + .map(|(index, paths)| ParamSkeleton { index, paths }) + .collect(); + let rets: Vec<&ReturnSkeleton> = group.iter().map(|s| &s.ret).collect(); + MethodSkeleton { + key: key.to_owned(), + params, + ret: merge_returns(&rets), + } +} + +/// `_forward_path_action`: a sink extern is a resolved transfer; anything +/// else is a forward edge (per-overload key when the sig names one). +fn forward_path_action( + callee: &str, + sig: Option<&str>, + arg: i64, + call_key: &dyn Fn(&str, Option<&str>) -> String, +) -> PathAction { + match callee { + "$consume" => PathAction::Dispose, + "$borrow" => PathAction::Borrow, + // $borrow_mut deliberately absent (no shared-vs-exclusive axis in the + // transfer lattice) — it falls through to a forward edge -> unknown. + _ => PathAction::Forward { + callee: call_key(callee, sig), + arg, + }, + } +} + +/// `_build_skeletons`: one merged skeleton per bare name plus one per emitted +/// `name(sig)` overload group. +fn build_skeletons(raw_fns: &[Value]) -> Vec { + let mut counts: HashMap = HashMap::new(); + for f in raw_fns.iter().filter_map(Value::as_object) { + let name = str_or(f, "name", ""); + let c = counts.entry(name).or_insert(0); + *c = c.saturating_add(1); + } + let first_party: HashSet = counts + .keys() + .filter(|k| !k.is_empty()) + .map(|k| canonical(k).to_owned()) + .collect(); + + let mut sig_keys: HashSet = HashSet::new(); + for f in raw_fns.iter().filter_map(Value::as_object) { + let name = str_or(f, "name", ""); + if !name.is_empty() && counts.get(&name).copied().unwrap_or(0) > 1 { + if let Some(fsig) = call_sig(f) { + sig_keys.insert(sig_key(&name, fsig)); + } + } + } + + let call_key = |callee: &str, sig: Option<&str>| -> String { + if let Some(sig) = sig { + for cand in [sig_key(callee, sig), sig_key(canonical(callee), sig)] { + if sig_keys.contains(&cand) { + return cand; + } + } + } + if !counts.contains_key(callee) { + let identity = canonical(callee); + if counts.contains_key(identity) { + return identity.to_owned(); + } + } + callee.to_owned() + }; + + // insertion-ordered groups (order only affects solver internals, which + // are order-independent; kept for a faithful walk). + let mut by_key: Vec<(String, Vec)> = Vec::new(); + let mut by_sig: Vec<(String, Vec)> = Vec::new(); + fn push_group(groups: &mut Vec<(String, Vec)>, key: &str, sk: MethodSkeleton) { + if let Some((_, g)) = groups.iter_mut().find(|(k, _)| k == key) { + g.push(sk); + } else { + groups.push((key.to_owned(), vec![sk])); + } + } + + for f in raw_fns.iter().filter_map(Value::as_object) { + let key = str_or(f, "name", ""); + if key.is_empty() { + continue; + } + let body = as_list(f.get("body")); + let raw_params = as_list(f.get("params")); + let mut params: Vec = Vec::new(); + for (i, p) in raw_params.iter().enumerate() { + let Some(p) = p.as_object() else { continue }; + let cname = str_or(p, "name", "?"); + let eff = p.get("effect"); + let paths: Vec = match eff.and_then(Value::as_str) { + Some("consume") => vec![PathAction::Dispose], // explicit override + Some("borrow" | "borrow_mut") => vec![PathAction::Borrow], + Some(_) => Vec::new(), // explicit non-owning + None => { + let (rel, passed, used) = param_signals(&cname, body); + if rel { + if definite_release(&cname, body) { + vec![PathAction::Dispose] + } else { + // partial release (TZ D1): a kept path exists, so + // the join is `may`, never a flattened `must`. + vec![PathAction::Dispose, PathAction::Borrow] + } + } else if passed { + let allt = forward_targets(&cname, body, true); + let top = forward_targets(&cname, body, false); + let mut paths: Vec = allt + .iter() + .map(|(c, s, j)| forward_path_action(c, s.as_deref(), *j, &call_key)) + .collect(); + if !(allt.len() == 1 + && top.len() == 1 + && !early_return_before_forward(&cname, body)) + { + // not a single unconditional handoff: a + // no-transfer path exists -> `may`/`no`. + paths.push(PathAction::Borrow); + } + paths + } else if used { + vec![PathAction::Borrow] + } else { + Vec::new() + } + } + }; + params.push(ParamSkeleton { + index: i64::try_from(i).unwrap_or(i64::MAX), + paths, + }); + } + let pnames: HashSet = raw_params + .iter() + .filter_map(Value::as_object) + .map(|p| str_or(p, "name", "")) + .collect(); + let ret = infer_return_skeleton(body, &pnames, &first_party, &call_key); + let sk = MethodSkeleton { + key: key.clone(), + params, + ret, + }; + if counts.get(&key).copied().unwrap_or(0) > 1 { + if let Some(fsig) = call_sig(f) { + push_group(&mut by_sig, &sig_key(&key, fsig), sk.clone()); + } + } + push_group(&mut by_key, &key, sk); + } + + let mut out: Vec = by_key + .iter() + .map(|(k, group)| merge_skeletons(k, group)) + .collect(); + out.extend(by_sig.iter().map(|(k, group)| merge_skeletons(k, group))); + out +} + +// --- the optimistic-default machinery (untrack / kill sites) ------------------ + +/// `_unverified_transfer_calls`, reduced to the arg-name set `to_module` +/// derives from it (the OWN051 advisory channel does not touch the lowered +/// document, so the callee/transfer/line tuple members are not carried). +fn unverified_arg_names(nodes: &[Value], mos: &Mos) -> HashSet { + let mut out = HashSet::new(); + fn walk(nodes: &[Value], mos: &Mos, out: &mut HashSet) { + for n in nodes { + let Some(n) = n.as_object() else { continue }; + match get_str(n, "op") { + Some("call") => { + let callee = str_or(n, "callee", ""); + if let Some(summ) = mos_lookup(mos, &callee, call_sig(n)) { + if let Some(args) = n.get("args").and_then(Value::as_array) { + for (j, a) in args.iter().enumerate() { + let j = i64::try_from(j).unwrap_or(i64::MAX); + let ps = summ.params.iter().find(|q| q.index == j); + if ps.is_some_and(|q| { + matches!(q.transfer, Transfer::May | Transfer::Unknown) + }) { + out.insert(py_str(a)); + } + } + } + } + } + Some("if") => { + walk(as_list(n.get("then")), mos, out); + walk(as_list(n.get("else")), mos, out); + } + Some("while") => walk(as_list(n.get("body")), mos, out), + _ => {} + } + } + } + walk(nodes, mos, &mut out); + out +} + +/// `_kill_sites_for_unverified`: local name → the TOP-LEVEL call node where +/// its tracking stops (Python keys on `id(n)`; here the node's identity is +/// its address in the facts value tree, stable for the whole lowering). +fn kill_sites_for_unverified<'v>(nodes: &'v [Value], mos: &Mos) -> HashMap { + let mut sites: HashMap = HashMap::new(); + let mut minted: HashSet = HashSet::new(); + + fn collect_mints(n: &Value, minted: &mut HashSet) { + let Some(n) = n.as_object() else { return }; + match get_str(n, "op") { + Some("acquire" | "alias_join") => { + if let Some(v) = get_str(n, "var") { + minted.insert(v.to_owned()); + } + } + Some("call") => { + if let Some(r) = get_str(n, "result") { + if !r.is_empty() { + minted.insert(r.to_owned()); + } + } + } + Some("if") => { + for key in ["then", "else"] { + for x in as_list(n.get(key)) { + collect_mints(x, minted); + } + } + } + Some("while") => { + for x in as_list(n.get("body")) { + collect_mints(x, minted); + } + } + _ => {} + } + } + + for n_v in nodes { + if let Some(n) = n_v.as_object() { + if get_str(n, "op") == Some("call") { + let callee = str_or(n, "callee", ""); + if let Some(summ) = mos_lookup(mos, &callee, call_sig(n)) { + if let Some(args) = n.get("args").and_then(Value::as_array) { + for (j, a) in args.iter().enumerate() { + let j = i64::try_from(j).unwrap_or(i64::MAX); + let ps = summ.params.iter().find(|q| q.index == j); + let aname = py_str(a); + if ps.is_some_and(|q| { + matches!(q.transfer, Transfer::May | Transfer::Unknown) + }) && minted.contains(&aname) + && !sites.contains_key(&aname) + { + sites.insert(aname, n_v); + } + } + } + } + } + } + collect_mints(n_v, &mut minted); + } + sites +} + +// --- branch-local hoisting ---------------------------------------------------- + +/// `_branch_hoist_safe`: hoisting is leak-safe only if no path can exit +/// before the post-merge release on a path that did not acquire `name`. +fn branch_hoist_safe( + nodes: &[Value], + name: &str, + mos: &Mos, + first_party: &HashSet, +) -> bool { + let is_acq = |n: &Obj| -> bool { + match get_str(n, "op") { + Some("acquire") => str_or(n, "var", "") == name, + Some("call") => { + str_or(n, "result", "") == name + && callee_returns_fresh(&str_or(n, "callee", ""), mos, first_party, call_sig(n)) + } + _ => false, + } + }; + // (safe, acquired_after); safe = false => a fabricated-leak exit exists. + fn analyze( + seq: &[Value], + mut acquired: bool, + name: &str, + is_acq: &dyn Fn(&Obj) -> bool, + ) -> (bool, bool) { + for n in seq { + let Some(n) = n.as_object() else { continue }; + if is_acq(n) { + acquired = true; + } else { + match get_str(n, "op") { + Some("return") => { + if !acquired && str_or(n, "var", "") != name { + return (false, acquired); + } + } + Some("if") => { + let (s1, a1) = analyze(as_list(n.get("then")), acquired, name, is_acq); + if !s1 { + return (false, acquired); + } + let (s2, a2) = analyze(as_list(n.get("else")), acquired, name, is_acq); + if !s2 { + return (false, acquired); + } + acquired = acquired || (a1 && a2); + } + Some("while") => { + // 0-trip: no acquisition gained + let (s, _) = analyze(as_list(n.get("body")), acquired, name, is_acq); + if !s { + return (false, acquired); + } + } + _ => {} + } + } + } + (true, acquired) + } + analyze(nodes, false, name, &is_acq).0 +} + +/// `_hoisted_branch_locals`: name → (first branch-acquire line, pool kind). +fn hoisted_branch_locals( + nodes: &[Value], + mos: &Mos, + first_party: &HashSet, +) -> HashMap { + let mut acq_depth: HashMap = HashMap::new(); + let mut acq_line: HashMap = HashMap::new(); + let mut acq_pool: HashMap = HashMap::new(); + let mut ref_depth: HashMap = HashMap::new(); + let mut loop_acq: HashSet = HashSet::new(); + + struct W<'a> { + mos: &'a Mos, + first_party: &'a HashSet, + acq_depth: &'a mut HashMap, + acq_line: &'a mut HashMap, + acq_pool: &'a mut HashMap, + ref_depth: &'a mut HashMap, + loop_acq: &'a mut HashSet, + } + + fn walk(w: &mut W<'_>, nodes: &[Value], depth: u32, in_loop: bool) { + for n in nodes { + let Some(n) = n.as_object() else { continue }; + let op = get_str(n, "op"); + let line = as_int(n.get("line")); + let acq: Option = match op { + Some("acquire") => Some(str_or(n, "var", "?")), + Some("call") => { + let (callee, res) = (get_str(n, "callee"), get_str(n, "result")); + match (callee, res) { + (Some(c), Some(r)) if !c.is_empty() && !r.is_empty() => { + callee_returns_fresh(c, w.mos, w.first_party, call_sig(n)) + .then(|| r.to_owned()) + } + _ => None, + } + } + _ => None, + }; + if let Some(acq) = acq { + let d = w.acq_depth.entry(acq.clone()).or_insert(depth); + *d = (*d).min(depth); + w.acq_line.entry(acq.clone()).or_insert(line); + if op == Some("acquire") && n.get("kind").and_then(Value::as_str) == Some("pool") { + w.acq_pool.insert(acq.clone(), true); + } + if in_loop { + w.loop_acq.insert(acq); + } + } + match op { + Some("use" | "release" | "overspan" | "return") => { + if let Some(v) = get_str(n, "var") { + let d = w.ref_depth.entry(v.to_owned()).or_insert(depth); + *d = (*d).min(depth); + } + } + Some("call") => { + for a in as_list(n.get("args")) { + let name = py_str(a); + let d = w.ref_depth.entry(name).or_insert(depth); + *d = (*d).min(depth); + } + } + _ => {} + } + match op { + Some("if") => { + walk(w, as_list(n.get("then")), depth.saturating_add(1), in_loop); + walk(w, as_list(n.get("else")), depth.saturating_add(1), in_loop); + } + Some("while") => walk(w, as_list(n.get("body")), depth.saturating_add(1), true), + _ => {} + } + } + } + + let mut w = W { + mos, + first_party, + acq_depth: &mut acq_depth, + acq_line: &mut acq_line, + acq_pool: &mut acq_pool, + ref_depth: &mut ref_depth, + loop_acq: &mut loop_acq, + }; + walk(&mut w, nodes, 0, false); + + acq_depth + .iter() + .filter(|(name, d)| { + **d >= 1 + && ref_depth.get(*name).copied() == Some(0) + && !loop_acq.contains(*name) + && branch_hoist_safe(nodes, name, mos, first_party) + }) + .map(|(name, _)| { + ( + name.clone(), + ( + acq_line.get(name).copied().unwrap_or(0), + acq_pool.get(name).copied().unwrap_or(false), + ), + ) + }) + .collect() +} + +// --- handle-entry construction ----------------------------------------------- + +fn unrepresentable(key: &str, v: &Value) -> BridgeError { + BridgeError(format!( + "handle metadata key '{key}' carries a value the typed Layer 2 \ + surface cannot represent ({v}) — extend the contract deliberately \ + instead of coercing" + )) +} + +fn want_str(rec: &Obj, key: &str) -> Result, BridgeError> { + match rec.get(key) { + None => Ok(None), + Some(Value::String(s)) => Ok(Some(s.clone())), + Some(other) => Err(unrepresentable(key, other)), + } +} + +fn want_i64(rec: &Obj, key: &str) -> Result, BridgeError> { + rec.get(key).map_or(Ok(None), |v| { + v.as_i64().map(Some).ok_or_else(|| unrepresentable(key, v)) + }) +} + +fn want_bool(rec: &Obj, key: &str) -> Result, BridgeError> { + match rec.get(key) { + None => Ok(None), + Some(Value::Bool(b)) => Ok(Some(*b)), + Some(other) => Err(unrepresentable(key, other)), + } +} + +fn want_maybe(rec: &Obj, key: &str) -> Result, BridgeError> { + match rec.get(key) { + None => Ok(Maybe::Missing), + Some(Value::Null) => Ok(Maybe::Null), + Some(Value::String(s)) => Ok(Maybe::Value(s.clone())), + Some(other) => Err(unrepresentable(key, other)), + } +} + +/// A subscription-fact handle record: `{**sub, component, file[, di_source_life]}` +/// projected through the `_HANDLE_KEYS` allowlist by key MEMBERSHIP. +fn subscription_entry( + handle: &str, + sub: &Obj, + cname: &str, + comp_file: Option<&Value>, + di_source_life: Option<&str>, +) -> Result { + let mut rec = sub.clone(); + rec.insert("component".to_owned(), Value::String(cname.to_owned())); + rec.insert( + "file".to_owned(), + comp_file + .cloned() + .unwrap_or_else(|| Value::String("?".to_owned())), + ); + if let Some(dl) = di_source_life { + rec.insert("di_source_life".to_owned(), Value::String(dl.to_owned())); + } + Ok(HandleEntry { + handle: handle.to_owned(), + component: want_str(&rec, "component")?, + file: want_str(&rec, "file")?, + line: want_i64(&rec, "line")?, + event: want_str(&rec, "event")?, + handler: want_str(&rec, "handler")?, + resource: want_str(&rec, "resource")?, + released: want_bool(&rec, "released")?, + source: want_maybe(&rec, "source")?, + source_type: want_maybe(&rec, "source_type")?, + di_source_life: want_str(&rec, "di_source_life")?, + type_name: want_maybe(&rec, "type")?, + ever_released: want_bool(&rec, "ever_released")?, + pool: want_bool(&rec, "pool")?, + }) +} + +/// A flow-local handle record (`parg_*` carries no `pool` key; `loc_*` does). +fn flow_local_entry( + handle: &str, + file: &str, + line: i64, + event: &str, + component: &str, + ever_released: bool, + pool: Option, +) -> HandleEntry { + HandleEntry { + handle: handle.to_owned(), + component: Some(component.to_owned()), + file: Some(file.to_owned()), + line: Some(line), + event: Some(event.to_owned()), + handler: None, + resource: Some("flow-local".to_owned()), + released: None, + source: Maybe::Missing, + source_type: Maybe::Missing, + di_source_life: None, + type_name: Maybe::Missing, + ever_released: Some(ever_released), + pool, + } +} + +// --- DI registrations --------------------------------------------------------- + +/// `_di_life_map`: DI-registered service name → its lifetime. +fn di_life_map(root: &Obj) -> HashMap { + let mut out = HashMap::new(); + for s in as_list(root.get("services")) + .iter() + .filter_map(Value::as_object) + { + if let (Some(name), Some(lt)) = (get_str(s, "name"), get_str(s, "lifetime")) { + if di_region(lt).is_some() { + out.insert(name.to_owned(), lt.to_owned()); + } + } + } + out +} + +/// `_subscriber_region`. +fn subscriber_region(cname: &str, di_life: &HashMap) -> String { + di_life + .get(cname) + .and_then(|lt| di_region(lt)) + .unwrap_or(SUBSCRIBER_REGION) + .to_owned() +} + +// --- function-parameter lowering ---------------------------------------------- + +/// `_lower_fn_params`. +#[allow(clippy::too_many_arguments)] // mirrors the reference signature +fn lower_fn_params( + f: &Obj, + ffile: &str, + fname: &str, + handles: &mut Vec, + loc: &mut i64, + localmap: &mut HashMap, + released: &HashSet, + mos: &Mos, +) -> Vec { + let mut out = Vec::new(); + let Some(raw) = f.get("params").and_then(Value::as_array) else { + return out; + }; + let summ = mos_lookup(mos, fname, call_sig(f)); + for (i, p) in raw.iter().enumerate() { + let Some(p) = p.as_object() else { continue }; + let cname = str_or(p, "name", "?"); + let eff: Option<&str> = p.get("effect").and_then(Value::as_str).or_else(|| { + let ftrans = summ.and_then(|s| { + s.params + .iter() + .find(|q| q.index == i64::try_from(i).unwrap_or(i64::MAX)) + .map(|q| q.transfer) + }); + infer_param_effect(&cname, as_list(f.get("body")), ftrans) + }); + let tref = match eff { + Some("consume") => TypeShape { + name: "Disposable".to_owned(), + borrowed: false, + mutable: false, + }, + Some("borrow") => TypeShape { + name: "Disposable".to_owned(), + borrowed: true, + mutable: false, + }, + Some("borrow_mut") => TypeShape { + name: "Disposable".to_owned(), + borrowed: true, + mutable: true, + }, + _ => TypeShape { + name: "int".to_owned(), // a plain (non-owned) parameter + borrowed: false, + mutable: false, + }, + }; + let sym = format!("parg_{loc}"); + *loc = loc.saturating_add(1); + let line = as_int(p.get("line")); + localmap.insert(cname.clone(), sym.clone()); + handles.push(flow_local_entry( + &sym, + ffile, + line, + &cname, + fname, + released.contains(&cname), + None, + )); + out.push(Param { + handle: sym, + type_shape: tref, + line, + lifetime: None, + }); + } + out +} + +// --- flow lowering (`_lower_flow`) -------------------------------------------- + +struct FnCtx<'v, 'a> { + ffile: &'a str, + fname: &'a str, + handles: &'a mut Vec, + loc: &'a mut i64, + localmap: &'a mut HashMap, + released: &'a HashSet, + mos: &'a Mos, + hoisted: &'a BTreeSet, + first_party: &'a HashSet, + overloaded: &'a HashSet, + untracked: &'a HashSet, + kill_sites: &'a HashMap, +} + +fn lower_flow<'v>(ctx: &mut FnCtx<'v, '_>, nodes: &'v [Value]) -> Result, BridgeError> { + let mut body: Vec = Vec::new(); + for n_v in nodes { + let Some(n) = n_v.as_object() else { continue }; + let op = get_str(n, "op"); + let line = as_int(n.get("line")); + match op { + Some("acquire") => { + let name = str_or(n, "var", "?"); + if ctx.hoisted.contains(&name) || ctx.untracked.contains(&name) { + continue; + } + let handle = format!("loc_{}", ctx.loc); + *ctx.loc = ctx.loc.saturating_add(1); + ctx.localmap.insert(name.clone(), handle.clone()); + ctx.handles.push(flow_local_entry( + &handle, + ctx.ffile, + line, + &name, + ctx.fname, + ctx.released.contains(&name), + Some(n.get("kind").and_then(Value::as_str) == Some("pool")), + )); + body.push(Stmt::Acquire { + handle, + resource: "Disposable".to_owned(), + line, + }); + } + Some("alias_join") => { + let name = str_or(n, "var", "?"); + let src_h = ctx.localmap.get(&str_or(n, "src", "")).cloned(); + // the OLD binding dies FIRST, even when the new alias makes + // no claim (an unreleased original leaks, never silently + // discharges through the dead handle). + if !ctx.hoisted.contains(&name) { + ctx.localmap.remove(&name); + } + if let Some(src_h) = src_h { + if !ctx.hoisted.contains(&name) && !ctx.untracked.contains(&name) { + let handle = format!("loc_{}", ctx.loc); + *ctx.loc = ctx.loc.saturating_add(1); + ctx.localmap.insert(name.clone(), handle.clone()); + ctx.handles.push(flow_local_entry( + &handle, + ctx.ffile, + line, + &name, + ctx.fname, + ctx.released.contains(&name), + Some(false), + )); + body.push(Stmt::AliasJoin { + handle, + src: src_h, + line, + }); + } + } + } + Some("use") => { + let key = py_str(n.get("var").unwrap_or(&Value::Null)); + if let Some(h) = ctx.localmap.get(&key) { + body.push(Stmt::Use { + handle: h.clone(), + line, + }); + } + } + Some("overspan") => { + let key = py_str(n.get("var").unwrap_or(&Value::Null)); + if let Some(h) = ctx.localmap.get(&key) { + body.push(Stmt::Overspan { + handle: h.clone(), + line, + }); + } + } + Some("release") => { + let key = py_str(n.get("var").unwrap_or(&Value::Null)); + if let Some(h) = ctx.localmap.get(&key) { + body.push(Stmt::Release { + handle: h.clone(), + line, + }); + } + } + Some("return") => { + let h = n + .get("var") + .filter(|v| !v.is_null()) + .and_then(|v| ctx.localmap.get(&py_str(v))) + .cloned(); + body.push(Stmt::Return { handle: h, line }); + } + Some("if") => { + let then_b = lower_flow(ctx, as_list(n.get("then")))?; + let else_b = lower_flow(ctx, as_list(n.get("else")))?; + body.push(Stmt::If { + cond: "?".to_owned(), + then: then_b, + r#else: else_b, + line, + }); + } + Some("while") => { + let body_b = lower_flow(ctx, as_list(n.get("body")))?; + body.push(Stmt::While { + cond: "?".to_owned(), + body: body_b, + line, + }); + } + Some("call") => { + let callee = str_or(n, "callee", ""); + let identity = canonical(&callee); + let args = n.get("args").and_then(Value::as_array); + let mos = ctx.mos; + // the direct-`Call` gate stays on the RAW name so it never + // names a callee absent from the core signature table. + let summ_raw = if callee.is_empty() { + None + } else { + mos.get(&callee) + }; + // stage-2 resolution for the channel: per-overload sig key + // first, then the name-merged fallback. + let resolved = mos_lookup(mos, &callee, call_sig(n)); + let channel_case = resolved.is_some_and(|r| { + args.is_some() + && (ctx.overloaded.contains(identity) + || r.params + .iter() + .any(|q| matches!(q.transfer, Transfer::May | Transfer::Unknown))) + }); + if channel_case { + let resolved = resolved.expect("channel_case implies resolved"); + for (j, a) in args.expect("channel_case implies args").iter().enumerate() { + let j = i64::try_from(j).unwrap_or(i64::MAX); + let channel = resolved.params.iter().find(|q| q.index == j).and_then(|q| { + match q.transfer { + Transfer::Must => Some("$consume"), + Transfer::No => Some("$borrow"), + Transfer::May | Transfer::Unknown => None, + } + }); + if let Some(channel) = channel { + let aname = py_str(a); + if !ctx.untracked.contains(&aname) { + let arg = ctx.localmap.get(&aname).cloned().unwrap_or(aname); + body.push(Stmt::Call { + callee: channel.to_owned(), + args: vec![arg], + line, + }); + } + } + } + } else if (summ_raw.is_some() || SINK_EXTERN_NAMES.contains(&callee.as_str())) + && !callee.is_empty() + { + if let Some(args) = args { + let arg_refs = args + .iter() + .map(|a| { + let s = py_str(a); + ctx.localmap.get(&s).cloned().unwrap_or(s) + }) + .collect(); + body.push(Stmt::Call { + callee: callee.clone(), + args: arg_refs, + line, + }); + } + } + // the kill site of a tracked local: discharge here, unmap after. + if !ctx.kill_sites.is_empty() { + if let Some(args) = args { + for a in args { + let aname = py_str(a); + if ctx + .kill_sites + .get(&aname) + .is_some_and(|site| std::ptr::eq(*site, n_v)) + { + if let Some(killed) = ctx.localmap.remove(&aname) { + body.push(Stmt::Call { + callee: "$consume".to_owned(), + args: vec![killed], + line, + }); + } + } + } + } + } + // result rebind kills the old binding; a fresh-returning + // callee then mints a new obligation for the result. + let result = get_str(n, "result").filter(|r| !r.is_empty()); + if let Some(result) = result { + if !ctx.hoisted.contains(result) { + ctx.localmap.remove(result); + if !ctx.untracked.contains(result) + && callee_returns_fresh(&callee, mos, ctx.first_party, call_sig(n)) + { + let handle = format!("loc_{}", ctx.loc); + *ctx.loc = ctx.loc.saturating_add(1); + ctx.localmap.insert(result.to_owned(), handle.clone()); + ctx.handles.push(flow_local_entry( + &handle, + ctx.ffile, + line, + result, + ctx.fname, + ctx.released.contains(result), + Some(false), + )); + body.push(Stmt::Acquire { + handle, + resource: "Disposable".to_owned(), + line, + }); + } + } + } + } + _ => { + return Err(BridgeError(format!( + "unknown OwnIR flow op {} ({}:{line}) — extractor/core \ + vocabulary skew; a new op must bump OWNIR_VERSION (see \ + spec/OwnIR.md)", + py_repr(n.get("op")), + ctx.ffile, + ))) + } + } + } + Ok(body) +} + +// --- the entry point ---------------------------------------------------------- + +pub(crate) fn lower(facts: &OwnIr) -> Result { + let root_value = facts.to_value().map_err(|e| BridgeError(e.to_string()))?; + let root = root_value + .as_object() + .expect("a struct serializes to an object"); + + let mut handles: Vec = Vec::new(); + let mut functions: Vec = Vec::new(); + let mut gid: i64 = 0; + let mut any_capture = false; + let di_life = di_life_map(root); + + // --- components: the subscription/capture lowering ----------------------- + let components: &[Value] = match root.get("components") { + None => &[], + Some(Value::Array(a)) => a.as_slice(), + Some(_) => { + return Err(BridgeError( + "OwnIR 'components' must be a JSON array".to_owned(), + )) + } + }; + for comp_v in components { + let Some(comp) = comp_v.as_object() else { + return Err(BridgeError( + "each OwnIR component must be a JSON object".to_owned(), + )); + }; + let cname = comp + .get("name") + .map_or_else(|| format!("Component{gid}"), py_str); + let mut body: Vec = Vec::new(); + let mut params: Vec = Vec::new(); + let mut fn_lt: Option = None; // the subscriber region, iff a capture + let self_region = subscriber_region(&cname, &di_life); + let subscriptions: &[Value] = match comp.get("subscriptions") { + None => &[], + Some(Value::Array(a)) => a.as_slice(), + Some(_) => { + return Err(BridgeError( + "component 'subscriptions' must be a JSON array".to_owned(), + )) + } + }; + for sub_v in subscriptions { + let Some(sub) = sub_v.as_object() else { + return Err(BridgeError( + "each subscription must be a JSON object".to_owned(), + )); + }; + let rkind = get_str(sub, "resource").unwrap_or("subscription"); + // R1: an unresolved-subscription marker is never lowered. + if rkind == "unresolved-subscription" { + continue; + } + // R2: a self-rooted subscribe is a GC-collectible self-cycle. + if rkind == "subscribe" && get_str(sub, "source") == Some("self") { + continue; + } + // R3: a `capture` routes through the lifetime/region engine. + if rkind == "capture" { + let region = get_str(sub, "source").and_then(capture_source_region); + let Some(region) = region else { continue }; + if py_truthy(sub.get("released")) { + continue; // mitigated — torn down on close + } + let handle = format!("cap_{gid}"); + gid = gid.saturating_add(1); + handles.push(subscription_entry( + &handle, + sub, + &cname, + comp.get("file"), + None, + )?); + let line = as_int(sub.get("line")); + params.push(Param { + handle: handle.clone(), + type_shape: TypeShape { + name: "EventSource".to_owned(), + borrowed: false, + mutable: false, + }, + line: 0, + lifetime: Some(region.to_owned()), + }); + body.push(Stmt::Subscribe { + source: handle, + line, + }); + fn_lt = Some(self_region.clone()); + any_capture = true; + continue; + } + // R4: instance-level provenance beats the type-level DI hop. + if rkind == "subscription" + && get_str(sub, "source") == Some("injected") + && get_str(sub, "source_provenance") == Some("returned_fresh") + { + continue; + } + // R5: an injected subscription whose source TYPE has a KNOWN DI + // lifetime reroutes through the region engine. + if rkind == "subscription" + && get_str(sub, "source") == Some("injected") + && !py_truthy(sub.get("released")) + { + let src_life = get_str(sub, "source_type").and_then(|st| di_life.get(st)); + if let Some(src_life) = src_life { + let src_life = src_life.clone(); + let handle = format!("cap_{gid}"); + gid = gid.saturating_add(1); + handles.push(subscription_entry( + &handle, + sub, + &cname, + comp.get("file"), + Some(&src_life), + )?); + let line = as_int(sub.get("line")); + params.push(Param { + handle: handle.clone(), + type_shape: TypeShape { + name: "EventSource".to_owned(), + borrowed: false, + mutable: false, + }, + line: 0, + lifetime: di_region(&src_life).map(str::to_owned), + }); + body.push(Stmt::Subscribe { + source: handle, + line, + }); + fn_lt = Some(self_region.clone()); + any_capture = true; + continue; + } + } + // R6: the acquire/release token path. + let handle = format!("sub_{gid}"); + gid = gid.saturating_add(1); + handles.push(subscription_entry( + &handle, + sub, + &cname, + comp.get("file"), + None, + )?); + let Some(rtype) = resource_type(rkind) else { + // Python's tolerant door falls back to Subscription here; that + // contract is an open decision (#294) this crate refuses to + // pre-empt in either direction — fail loud instead. + return Err(BridgeError(format!( + "unknown resource kind '{rkind}' — the tolerant-door \ + fallback is an open decision (#294); the Rust bridge \ + refuses to guess" + ))); + }; + let line = as_int(sub.get("line")); + body.push(Stmt::Acquire { + handle: handle.clone(), + resource: rtype.to_owned(), + line, + }); + if py_truthy(sub.get("released")) { + body.push(Stmt::Release { handle, line }); + } + } + functions.push(Function { + name: cname, + lifetime: fn_lt, + params, + ret: None, + body, + }); + } + + // --- functions: the per-method flow lowering (P-016 B0b/B2) --------------- + let mut loc: i64 = 0; + let raw_fns: &[Value] = match root.get("functions") { + Some(Value::Array(a)) => a.as_slice(), + _ => &[], // Python: a non-list `functions` skips the whole section + }; + // D5.1: resolve interprocedural transfer once, up front; degradation to + // an empty MOS mirrors Python's exception guard (not reachable from the + // production skeleton builder, which never emits duplicate keys). + let mos_map: Mos = mos::solve(build_skeletons(raw_fns)).unwrap_or_default(); + let fp_names: Vec = raw_fns + .iter() + .filter_map(Value::as_object) + .filter(|f| py_truthy(f.get("name"))) + .map(|f| str_or(f, "name", "")) + .collect(); + let first_party: HashSet = fp_names.iter().map(|n| canonical(n).to_owned()).collect(); + let overloaded: HashSet = { + let mut counts: HashMap<&str, usize> = HashMap::new(); + for n in &fp_names { + let c = counts.entry(canonical(n)).or_insert(0); + *c = c.saturating_add(1); + } + counts + .into_iter() + .filter(|(_, c)| *c > 1) + .map(|(n, _)| n.to_owned()) + .collect() + }; + for fn_v in raw_fns { + let Some(f) = fn_v.as_object() else { continue }; + let fname = f.get("name").map_or_else(|| format!("Fn{loc}"), py_str); + let ffile = str_or(f, "file", "?"); + let nodes: &[Value] = as_list(f.get("body")); + let released = released_vars(nodes); + let mut localmap: HashMap = HashMap::new(); + let fparams = lower_fn_params( + f, + &ffile, + &fname, + &mut handles, + &mut loc, + &mut localmap, + &released, + &mos_map, + ); + // the optimistic default (d5 §5): a may/unknown-contract handoff + // discharges at a TOP-LEVEL call (kill site) or untracks whole-body. + let unverified = unverified_arg_names(nodes, &mos_map); + let kill_sites = kill_sites_for_unverified(nodes, &mos_map); + let untracked: HashSet = unverified + .into_iter() + .filter(|a| !kill_sites.contains_key(a)) + .collect(); + // cross-branch locals declared once at the outer scope; an untracked + // local must NOT be hoisted (it would re-mint the removed obligation). + let hoist: BTreeMap = + hoisted_branch_locals(nodes, &mos_map, &first_party) + .into_iter() + .filter(|(k, _)| !untracked.contains(k)) + .collect(); + let hoisted_set: BTreeSet = hoist.keys().cloned().collect(); + let mut fbody: Vec = Vec::new(); + for (hname, (hline, hpool)) in &hoist { + let hh = format!("loc_{loc}"); + loc = loc.saturating_add(1); + localmap.insert(hname.clone(), hh.clone()); + handles.push(flow_local_entry( + &hh, + &ffile, + *hline, + hname, + &fname, + released.contains(hname), + Some(*hpool), + )); + fbody.push(Stmt::Acquire { + handle: hh, + resource: "Disposable".to_owned(), + line: *hline, + }); + } + let mut ctx = FnCtx { + ffile: &ffile, + fname: &fname, + handles: &mut handles, + loc: &mut loc, + localmap: &mut localmap, + released: &released, + mos: &mos_map, + hoisted: &hoisted_set, + first_party: &first_party, + overloaded: &overloaded, + untracked: &untracked, + kill_sites: &kill_sites, + }; + fbody.extend(lower_flow(&mut ctx, nodes)?); + // a value-returning body gets an owned return type so `return s` + // models a valid escape (discharge), not a void-return mismatch. + let fret = returns_value(nodes).then(|| TypeShape { + name: "Disposable".to_owned(), + borrowed: false, + mutable: false, + }); + functions.push(Function { + name: fname, + lifetime: None, + params: fparams, + ret: fret, + body: fbody, + }); + } + + let module = root + .get("module") + .map_or_else(|| "Extracted".to_owned(), py_str); + Ok(LoweredDocument { + lowered_version: LOWERED_VERSION, + module, + resources: prelude_resources(), + externs: sink_externs(), + lifetimes: if any_capture { + capture_lifetimes() + } else { + Vec::new() + }, + functions, + handles, + }) +} diff --git a/rust/crates/own-bridge/src/mos.rs b/rust/crates/own-bridge/src/mos.rs new file mode 100644 index 00000000..52b43237 --- /dev/null +++ b/rust/crates/own-bridge/src/mos.rs @@ -0,0 +1,380 @@ +//! Method Ownership Summaries — the `ownlang/ownership.py` solver, ported in +//! the shape `ownir.py::_build_skeletons` actually produces (P-005 D5.0). +//! +//! The solver resolves each method's per-parameter ownership transfer by a +//! least fixpoint over the call graph's SCC condensation, and each method's +//! owned-return kind by a memoized, cycle-safe chase along forward edges. +//! +//! Deliberately NOT carried from the reference: the `adopt`/`return` path +//! kinds and the `aliasOf`/`aliased` return kinds (reserved in Python — the +//! production skeleton builder never emits them; they would contribute +//! `Transfer::Must` / terminal strings exactly like Python's), the `escapes` +//! axis (no producer sets it), and the unresolved-edge log (`solve_with_log`) +//! — none of them can influence a lowered document today. The summary also +//! drops the dump-only fields (`name`, `file`, `line`, `source`): the merged +//! name/location tie-breaks in `_merge_skeletons` affect only the detached +//! summaries artifact, never the lowering. + +// Solver internals index maps by invariant-backed keys (every key read was +// inserted by the same pass); expect()/indexing over those invariants is the +// faithful shape of the reference and never input-reachable. `solve` mirrors +// the Python function boundaries rather than splitting for a line count. +// `redundant_pub_crate` (nursery) conflicts with the workspace's DENY of +// `unreachable_pub` for items in private modules; pub(crate) is the honest +// visibility here. +#![allow( + clippy::expect_used, + clippy::indexing_slicing, + clippy::too_many_lines, + clippy::redundant_pub_crate +)] + +use std::collections::{BTreeMap, BTreeSet, HashMap}; + +/// Did ownership of a disposable parameter leave the caller? +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Transfer { + /// Borrowed; the caller keeps ownership. + No, + /// Transferred on every normal-return path. + Must, + /// Transferred on some paths but not all. + May, + /// Insufficient evidence (extern callee). + Unknown, +} + +/// Combine two paths' transfer verdicts: `unknown` is absorbing; any other +/// disagreement is `may` (path-dependent, and we know it). +pub(crate) const fn join(a: Transfer, b: Transfer) -> Transfer { + match (a, b) { + (Transfer::No, Transfer::No) => Transfer::No, + (Transfer::Must, Transfer::Must) => Transfer::Must, + (Transfer::Unknown, _) | (_, Transfer::Unknown) => Transfer::Unknown, + _ => Transfer::May, + } +} + +/// One thing a method body does with a parameter on one normal-return path. +/// The production builder emits exactly these three kinds. +#[derive(Debug, Clone)] +pub(crate) enum PathAction { + /// Releases it — ownership left the caller on this path (`must`). + Dispose, + /// Only reads/uses it — kept (`no`). + Borrow, + /// Hands it to `callee` at parameter position `arg` — resolved against + /// that callee's summary by the fixpoint. + Forward { callee: String, arg: i64 }, +} + +/// What a method returns, in the kinds the production builder emits +/// (`aliasOf`/`aliased` are reserved in Python and never produced). +#[derive(Debug, Clone)] +pub(crate) enum ReturnSkeleton { + /// No owned return (void / no claim). + None, + /// A newly-owned disposable the caller must release. + Fresh, + /// Returns the result of `callee` — chased through that summary. + Forward { callee: String }, + /// The conservative overload-merge result — fails closed. + Unknown, +} + +#[derive(Debug, Clone)] +pub(crate) struct ParamSkeleton { + /// The logical parameter index calls resolve by (never tuple offset). + pub index: i64, + /// Empty = nothing happens to it -> kept (`no`). + pub paths: Vec, +} + +#[derive(Debug, Clone)] +pub(crate) struct MethodSkeleton { + /// The call-graph identity: `{Type}.{Method}` or `{Type}.{Method}(sig)`. + pub key: String, + pub params: Vec, + pub ret: ReturnSkeleton, +} + +#[derive(Debug, Clone)] +pub(crate) struct ParamSummary { + pub index: i64, + pub transfer: Transfer, +} + +#[derive(Debug, Clone)] +pub(crate) struct MethodSummary { + pub params: Vec, + /// `"fresh" | "none" | "unknown"` — the terminal forms the production + /// skeletons can reach. Only `== "fresh"` is read by the lowering. + pub returns: String, +} + +/// The lowering consumes summaries through this map (`mos` in Python). +pub(crate) type Mos = HashMap; + +/// Dependency edges `M -> callees whose summaries M's summary reads`. +fn call_graph(sk: &BTreeMap) -> BTreeMap> { + let mut adj: BTreeMap> = BTreeMap::new(); + for (k, skel) in sk { + let deps = adj.entry(k.clone()).or_default(); + for p in &skel.params { + for a in &p.paths { + if let PathAction::Forward { callee, .. } = a { + if sk.contains_key(callee) { + deps.insert(callee.clone()); + } + } + } + } + if let ReturnSkeleton::Forward { callee } = &skel.ret { + if sk.contains_key(callee) { + deps.insert(callee.clone()); + } + } + } + adj +} + +/// Tarjan's SCCs, iterative, emitted bottom-up (every component precedes its +/// callers). Adjacency is ordered, so the walk is deterministic; the fixpoint +/// result is order-independent regardless (a least fixpoint on a lattice). +fn sccs(adj: &BTreeMap>) -> Vec> { + let mut index: HashMap<&str, usize> = HashMap::new(); + let mut low: HashMap<&str, usize> = HashMap::new(); + let mut on_stack: BTreeSet<&str> = BTreeSet::new(); + let mut stack: Vec<&str> = Vec::new(); + let mut out: Vec> = Vec::new(); + let mut counter = 0_usize; + for root in adj.keys() { + let root = root.as_str(); + if index.contains_key(root) { + continue; + } + index.insert(root, counter); + low.insert(root, counter); + counter = counter.wrapping_add(1); + stack.push(root); + on_stack.insert(root); + let mut work: Vec<(&str, Vec<&str>)> = + vec![(root, adj[root].iter().map(String::as_str).rev().collect())]; + while let Some((node, pending)) = work.last_mut() { + let node = *node; + let mut descended = false; + while let Some(w) = pending.pop() { + if !index.contains_key(w) { + index.insert(w, counter); + low.insert(w, counter); + counter = counter.wrapping_add(1); + stack.push(w); + on_stack.insert(w); + work.push((w, adj[w].iter().map(String::as_str).rev().collect())); + descended = true; + break; + } + if on_stack.contains(w) { + let lw = index[w]; + let ln = low.get_mut(node).expect("visited node has a low-link"); + *ln = (*ln).min(lw); + } + } + if descended { + continue; + } + if low[node] == index[node] { + let mut comp: Vec = Vec::new(); + loop { + let x = stack.pop().expect("component member on the stack"); + on_stack.remove(x); + comp.push(x.to_owned()); + if x == node { + break; + } + } + out.push(comp); + } + work.pop(); + if let Some((parent, _)) = work.last() { + let parent = *parent; + let child_low = low[node]; + let lp = low.get_mut(parent).expect("parent has a low-link"); + *lp = (*lp).min(child_low); + } + } + } + out +} + +type ParamKey = (String, i64); + +/// Resolve every method's MOS. A duplicate skeleton key is an error the +/// caller degrades on (Python: `ValueError` → the bridge drops to an empty +/// MOS rather than corrupting the call graph). +pub(crate) fn solve(skeletons: Vec) -> Result { + let mut sk: BTreeMap = BTreeMap::new(); + for s in skeletons { + if sk.contains_key(&s.key) { + return Err(format!("duplicate MethodSkeleton key: {}", s.key)); + } + sk.insert(s.key.clone(), s); + } + + let mut param_val: HashMap = HashMap::new(); + + // --- param transfers: bottom-up, per-SCC least fixpoint on the lattice -- + let adj = call_graph(&sk); + for comp in sccs(&adj) { + let mut members: Vec = Vec::new(); + for k in &comp { + for p in &sk[k].params { + members.push((k.clone(), p.index)); + } + } + if members.is_empty() { + continue; + } + // ⊥ ("no evidence yet") is the fixpoint seed on recursive edges only. + let mut cur: HashMap> = + members.iter().cloned().map(|m| (m, None)).collect(); + + let lookup = |callee: &str, + arg: i64, + param_val: &HashMap, + cur: &HashMap>| + -> Option { + let Some(skel) = sk.get(callee) else { + return Some(Transfer::Unknown); // extern, no summary + }; + if !skel.params.iter().any(|q| q.index == arg) { + return Some(Transfer::Unknown); // no such logical param + } + let keyp = (callee.to_owned(), arg); + if let Some(v) = param_val.get(&keyp) { + return Some(*v); + } + if let Some(v) = cur.get(&keyp) { + return *v; // same-SCC member, mid-fixpoint (may be ⊥) + } + Some(Transfer::Unknown) // unreachable under a correct topo order + }; + + let mut changed = true; + while changed { + changed = false; + for m in &members { + let p = sk[&m.0] + .params + .iter() + .find(|q| q.index == m.1) + .expect("member param exists"); + let new = if p.paths.is_empty() { + Some(Transfer::No) // nothing happens to it -> kept + } else { + let mut acc: Option = None; + for a in &p.paths { + let contrib = match a { + PathAction::Dispose => Some(Transfer::Must), + PathAction::Borrow => Some(Transfer::No), + PathAction::Forward { callee, arg } => { + lookup(callee, *arg, ¶m_val, &cur) + } + }; + acc = match (acc, contrib) { + (None, b) => b, + (a, None) => a, + (Some(a), Some(b)) => Some(join(a, b)), + }; + } + acc + }; + if new != cur[m] { + cur.insert(m.clone(), new); + changed = true; + } + } + } + for m in members { + // ⊥ (no evidence) finalizes as `no` (kept/borrowed). + let v = cur[&m].unwrap_or(Transfer::No); + param_val.insert(m, v); + } + } + + // --- returns: iterative, memoized, cycle-safe chase along forward edges -- + let mut ret_val: HashMap = HashMap::new(); + let resolve_return = |start: &str, ret_val: &mut HashMap| -> String { + if let Some(v) = ret_val.get(start) { + return v.clone(); + } + let mut path: Vec = Vec::new(); + let mut on_path: BTreeSet = BTreeSet::new(); + let mut key = start.to_owned(); + let val: String; + loop { + if let Some(v) = ret_val.get(&key) { + val = v.clone(); + break; + } + match &sk[&key].ret { + ReturnSkeleton::None => { + val = "none".to_owned(); + ret_val.insert(key.clone(), val.clone()); + break; + } + ReturnSkeleton::Fresh => { + val = "fresh".to_owned(); + ret_val.insert(key.clone(), val.clone()); + break; + } + ReturnSkeleton::Unknown => { + val = "unknown".to_owned(); + ret_val.insert(key.clone(), val.clone()); + break; + } + ReturnSkeleton::Forward { callee } => { + if !sk.contains_key(callee) { + val = "unknown".to_owned(); // extern, no summary + ret_val.insert(key.clone(), val.clone()); + break; + } + if *callee == key || on_path.contains(callee) { + // forward-return cycle: no ground + val = "unknown".to_owned(); + ret_val.insert(key.clone(), val.clone()); + break; + } + path.push(key.clone()); + on_path.insert(key.clone()); + key = callee.clone(); + } + } + } + // Propagate up the chain (`aliasOf:` remap degradation cannot occur — + // the production skeletons never produce it). + for k in path.into_iter().rev() { + ret_val.insert(k, val.clone()); + } + ret_val[start].clone() + }; + + let mut out: Mos = HashMap::new(); + let keys: Vec = sk.keys().cloned().collect(); + for key in keys { + let params = sk[&key] + .params + .iter() + .map(|p| ParamSummary { + index: p.index, + transfer: param_val + .get(&(key.clone(), p.index)) + .copied() + .unwrap_or(Transfer::No), + }) + .collect(); + let returns = resolve_return(&key, &mut ret_val); + out.insert(key.clone(), MethodSummary { params, returns }); + } + Ok(out) +} diff --git a/rust/crates/own-bridge/tests/mechanisms.rs b/rust/crates/own-bridge/tests/mechanisms.rs new file mode 100644 index 00000000..cca00d72 --- /dev/null +++ b/rust/crates/own-bridge/tests/mechanisms.rs @@ -0,0 +1,249 @@ +//! Mechanism-focused tests (#259 slice 3): where a byte-exact replay alone +//! does not PROVE the mechanism, pin it with a metamorphic or negative case — +//! reordering input changes minting predictably, kill-on-rebind really kills, +//! the precise overload channel does not unmap while the merged-may kill site +//! does, each hoisting negative gate blocks the hoist, and an unknown flow op +//! fails loud with Python's exact rejection text. + +#![allow(clippy::panic, clippy::expect_used)] + +use own_lowered::{Function, LoweredDocument, Stmt}; + +fn lower(text: &str) -> LoweredDocument { + let facts = own_ir::OwnIr::from_json(text).expect("facts parse"); + own_bridge::lower(&facts).expect("lowering succeeds") +} + +fn fun<'a>(doc: &'a LoweredDocument, name: &str) -> &'a Function { + doc.functions + .iter() + .find(|f| f.name == name) + .unwrap_or_else(|| panic!("no function {name:?} in the lowered document")) +} + +/// Two components, one static capture each. In [A, B] order A mints `cap_0`; +/// permuting the records to [B, A] hands `cap_0` to B — the global counter +/// follows document order (BR-L2/BR-D4: input order is semantic). +#[test] +fn record_order_changes_global_mint_order_predictably() { + let comp = |name: &str| { + format!( + r#"{{"name": "{name}", "file": "{name}.cs", "subscriptions": [ + {{"event": "SystemEvents.E", "handler": "H", "line": 3, + "resource": "capture", "source": "static"}}]}}"# + ) + }; + let doc_ab = lower(&format!( + r#"{{"ownir_version": 0, "module": "M", "components": [{}, {}]}}"#, + comp("A"), + comp("B") + )); + let doc_ba = lower(&format!( + r#"{{"ownir_version": 0, "module": "M", "components": [{}, {}]}}"#, + comp("B"), + comp("A") + )); + let owner_of = |doc: &LoweredDocument, handle: &str| -> String { + doc.handles + .iter() + .find(|h| h.handle == handle) + .and_then(|h| h.component.clone()) + .unwrap_or_else(|| panic!("no handle {handle:?}")) + }; + assert_eq!(owner_of(&doc_ab, "cap_0"), "A"); + assert_eq!(owner_of(&doc_ab, "cap_1"), "B"); + assert_eq!(owner_of(&doc_ba, "cap_0"), "B"); + assert_eq!(owner_of(&doc_ba, "cap_1"), "A"); +} + +/// A call-result overwrite of a tracked local KILLS its binding: the later +/// `release x` resolves to nothing (the original obligation leaks instead of +/// being silently discharged through a dead handle). +#[test] +fn kill_on_rebind_removes_the_old_mapping() { + let doc = lower( + r#"{"ownir_version": 0, "module": "M", "functions": [ + {"name": "F", "file": "F.cs", "body": [ + {"op": "acquire", "var": "x", "line": 2}, + {"op": "call", "callee": "Unknown.Make", "args": [], "result": "x", "line": 3}, + {"op": "release", "var": "x", "line": 4}]}]}"#, + ); + let body = &fun(&doc, "F").body; + assert!( + matches!(body.as_slice(), [Stmt::Acquire { .. }]), + "after the rebind the release must NOT resolve to the dead handle; \ + body: {body:?}" + ); +} + +/// A sig-carrying call to an overloaded name applies its OWN overload's +/// contract through the channel ($consume for a consume overload) and does +/// NOT unmap the argument — a later `use` still resolves to the same handle. +#[test] +fn precise_overload_channel_does_not_unmap() { + let doc = lower( + r#"{"ownir_version": 0, "module": "M", "functions": [ + {"name": "Take", "file": "F.cs", "sig": "System.IO.Stream", + "params": [{"name": "p", "line": 1, "effect": "consume"}], "body": []}, + {"name": "Take", "file": "F.cs", "sig": "System.String", + "params": [{"name": "p", "line": 2, "effect": "borrow"}], "body": []}, + {"name": "M", "file": "F.cs", "body": [ + {"op": "acquire", "var": "c", "line": 12}, + {"op": "call", "callee": "Take", "sig": "System.IO.Stream", + "args": ["c"], "line": 13}, + {"op": "use", "var": "c", "line": 14}]}]}"#, + ); + let body = &fun(&doc, "M").body; + assert!( + matches!( + body.as_slice(), + [ + Stmt::Acquire { handle: h1, .. }, + Stmt::Call { callee, args, .. }, + Stmt::Use { handle: h2, .. }, + ] if callee == "$consume" && args == std::slice::from_ref(h1) && h1 == h2 + ), + "expected acquire → $consume channel → use on the SAME still-mapped \ + handle; body: {body:?}" + ); +} + +/// A sig-LESS call to the same overloaded name resolves the conservative +/// merged contract (`may`), which is a top-level kill site: the obligation is +/// discharged with `$consume` AT the call and the name unmapped — the later +/// `release` stays silent. +#[test] +fn merged_may_consume_applies_the_kill_site_unmap() { + let doc = lower( + r#"{"ownir_version": 0, "module": "M", "functions": [ + {"name": "Take", "file": "F.cs", "sig": "System.IO.Stream", + "params": [{"name": "p", "line": 1, "effect": "consume"}], "body": []}, + {"name": "Take", "file": "F.cs", "sig": "System.String", + "params": [{"name": "p", "line": 2, "effect": "borrow"}], "body": []}, + {"name": "M", "file": "F.cs", "body": [ + {"op": "acquire", "var": "c", "line": 12}, + {"op": "call", "callee": "Take", "args": ["c"], "line": 13}, + {"op": "release", "var": "c", "line": 14}]}]}"#, + ); + let body = &fun(&doc, "M").body; + assert!( + matches!( + body.as_slice(), + [ + Stmt::Acquire { handle: h1, .. }, + Stmt::Call { callee, args, .. }, + ] if callee == "$consume" && args == std::slice::from_ref(h1) + ), + "expected acquire → kill-site $consume and a SILENT later release \ + (name unmapped); body: {body:?}" + ); +} + +/// Hoisting negative gates: each condition alone must block the hoist. +mod hoist_gates { + use super::{fun, lower, Stmt}; + + /// Positive control: an if-branch acquire referenced at depth 0 hoists — + /// one outer-scope acquire, empty branches, the release resolves to it. + #[test] + fn positive_control_hoists() { + let doc = lower( + r#"{"ownir_version": 0, "module": "M", "functions": [ + {"name": "F", "file": "F.cs", "body": [ + {"op": "if", "line": 2, + "then": [{"op": "acquire", "var": "r", "line": 3}], + "else": [{"op": "acquire", "var": "r", "line": 5}]}, + {"op": "release", "var": "r", "line": 7}]}]}"#, + ); + let body = &fun(&doc, "F").body; + assert!( + matches!( + body.as_slice(), + [ + Stmt::Acquire { handle: h1, line: 3, .. }, + Stmt::If { then, r#else, .. }, + Stmt::Release { handle: h2, .. }, + ] if then.is_empty() && r#else.is_empty() && h1 == h2 + ), + "expected a single hoisted outer acquire with empty branches; \ + body: {body:?}" + ); + } + + /// A depth-2 acquire whose shallowest reference is depth 1 is NOT hoisted + /// (function top is not the common dominator). + #[test] + fn nested_depth_reference_blocks_the_hoist() { + let doc = lower( + r#"{"ownir_version": 0, "module": "M", "functions": [ + {"name": "F", "file": "F.cs", "body": [ + {"op": "if", "line": 2, "then": [ + {"op": "if", "line": 3, + "then": [{"op": "acquire", "var": "r", "line": 4}], + "else": []}, + {"op": "release", "var": "r", "line": 6}], + "else": []}]}]}"#, + ); + let body = &fun(&doc, "F").body; + assert!( + matches!(body.as_slice(), [Stmt::If { .. }]), + "no hoisted outer acquire may appear; body: {body:?}" + ); + } + + /// A `while`-body acquire is never hoisted (iterations are cumulative). + #[test] + fn while_body_acquire_blocks_the_hoist() { + let doc = lower( + r#"{"ownir_version": 0, "module": "M", "functions": [ + {"name": "F", "file": "F.cs", "body": [ + {"op": "while", "line": 2, + "body": [{"op": "acquire", "var": "r", "line": 3}]}, + {"op": "release", "var": "r", "line": 5}]}]}"#, + ); + let body = &fun(&doc, "F").body; + assert!( + matches!(body.first(), Some(Stmt::While { body: b, .. }) + if matches!(b.as_slice(), [Stmt::Acquire { .. }])), + "the acquire must stay inside the loop; body: {body:?}" + ); + } + + /// An early `return` on a non-acquiring path blocks the hoist (the + /// unconditional hoisted acquire would fabricate a leak on that path). + #[test] + fn early_return_blocks_the_hoist() { + let doc = lower( + r#"{"ownir_version": 0, "module": "M", "functions": [ + {"name": "F", "file": "F.cs", "body": [ + {"op": "if", "line": 2, + "then": [{"op": "acquire", "var": "r", "line": 3}], + "else": [{"op": "return", "line": 5}]}, + {"op": "release", "var": "r", "line": 7}]}]}"#, + ); + let body = &fun(&doc, "F").body; + assert!( + matches!(body.first(), Some(Stmt::If { then, .. }) + if matches!(then.as_slice(), [Stmt::Acquire { .. }])), + "the acquire must stay inside the branch; body: {body:?}" + ); + } +} + +/// An unknown flow op is vocabulary skew: fail loud with Python's exact +/// rejection text, never drop the op (which would silently lose the +/// acquire/release facts nested inside it). +#[test] +fn unknown_flow_op_fails_loud_with_python_text() { + let facts = own_ir::OwnIr::from_json( + r#"{"ownir_version": 0, "module": "M", "functions": [ + {"name": "F", "file": "X.cs", "body": [{"op": "goto", "line": 7}]}]}"#, + ) + .expect("facts parse"); + let err = own_bridge::lower(&facts).expect_err("an unknown flow op must be rejected"); + assert_eq!( + err.to_string(), + "unknown OwnIR flow op 'goto' (X.cs:7) — extractor/core vocabulary \ + skew; a new op must bump OWNIR_VERSION (see spec/OwnIR.md)" + ); +} diff --git a/rust/crates/own-bridge/tests/replay.rs b/rust/crates/own-bridge/tests/replay.rs new file mode 100644 index 00000000..3cd000a9 --- /dev/null +++ b/rust/crates/own-bridge/tests/replay.rs @@ -0,0 +1,135 @@ +//! The slice-3 acceptance contract (#259): for every `rust_replay: true` +//! manifest case, +//! +//! ```text +//! facts.json → own_ir::OwnIr::from_json → own_bridge::lower +//! → own_lowered::to_canonical_json == golden.json (byte-exact) +//! ``` +//! +//! The golden is EXPECTED OUTPUT only — it is never parsed as an input to +//! construction (contrast `own-lowered/tests/replay.rs`, which round-trips the +//! golden through the typed model; here the document is BUILT from facts). +//! +//! Independently enforced here (not outsourced to Python or to own-lowered): +//! * `manifest.lowered_version == LOWERED_VERSION == 1`; +//! * exact ledger/tree equality — `unique(manifest names) == *.facts.json == +//! *.golden.json`; +//! * `tolerant_unknown_kind` stays the ONLY `rust_replay: false` case (#294 — +//! this crate takes no side on the tolerant door); +//! * every shared case's facts actually pass through the Rust lowering (a +//! lowering rejection must BE the golden — the `Rejected` form — never a +//! skip); +//! * lowering the same facts twice is byte-deterministic. + +#![allow(clippy::panic, clippy::expect_used)] + +use own_lowered::{to_canonical_json, Manifest, Rejected, Surface, LOWERED_VERSION}; +use std::collections::BTreeSet; + +const FIXDIR: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../tests/fixtures/lowered" +); + +fn read(name: &str) -> String { + let path = format!("{FIXDIR}/{name}"); + std::fs::read_to_string(&path).unwrap_or_else(|e| { + panic!( + "cannot read {path}: {e} — regenerate: python tests/test_lowered_fixtures.py --write" + ) + }) +} + +/// facts text → the canonical Layer 2 bytes, through the Rust pipeline only. +fn lower_bytes(facts_text: &str, case: &str) -> String { + let facts = own_ir::OwnIr::from_json(facts_text) + .unwrap_or_else(|e| panic!("{case}: own-ir rejected the shared facts: {e}")); + let surface = match own_bridge::lower(&facts) { + Ok(doc) => Surface::Lowered(doc), + Err(e) => Surface::Rejected(Rejected { + lowered_version: LOWERED_VERSION, + error: e.to_string(), + }), + }; + to_canonical_json(&surface).unwrap_or_else(|e| panic!("{case}: canonical emit failed: {e}")) +} + +#[test] +fn lowers_every_shared_facts_to_its_golden() { + let manifest: Manifest = + serde_json::from_str(&read("manifest.json")).expect("manifest.json parses (typed, strict)"); + assert_eq!( + manifest.lowered_version, LOWERED_VERSION, + "manifest lowered_version must match LOWERED_VERSION" + ); + + // Ledger/tree equality, independently of Python and of own-lowered. + let mut listed = BTreeSet::new(); + for case in &manifest.cases { + assert!( + listed.insert(case.name.clone()), + "duplicate manifest case name: {}", + case.name + ); + } + let mut facts_files = BTreeSet::new(); + let mut golden_files = BTreeSet::new(); + for entry in std::fs::read_dir(FIXDIR).expect("fixture directory is readable") { + let file = entry.expect("directory entry").file_name(); + let file = file.to_str().expect("fixture filenames are UTF-8"); + if let Some(stem) = file.strip_suffix(".facts.json") { + facts_files.insert(stem.to_owned()); + } else if let Some(stem) = file.strip_suffix(".golden.json") { + golden_files.insert(stem.to_owned()); + } + } + assert_eq!( + listed, facts_files, + "manifest names != *.facts.json on disk" + ); + assert_eq!( + listed, golden_files, + "manifest names != *.golden.json on disk" + ); + + let mut lowered = 0_u32; + let mut skipped = Vec::new(); + for case in &manifest.cases { + let golden = read(&format!("{}.golden.json", case.name)); + if !case.rust_replay { + assert!( + case.decision.is_some(), + "{}: a Python-only case must name the open decision it pins", + case.name + ); + skipped.push(case.name.clone()); + continue; + } + let facts_text = read(&format!("{}.facts.json", case.name)); + let emitted = lower_bytes(&facts_text, &case.name); + assert!( + emitted == golden, + "{}: Rust lowering is not byte-identical to the Python golden.\n\ + --- emitted ---\n{emitted}\n--- golden ---\n{golden}", + case.name + ); + // determinism: the same facts must lower byte-identically on re-run. + assert_eq!( + lower_bytes(&facts_text, &case.name), + emitted, + "{}: lowering is not deterministic", + case.name + ); + lowered = lowered.checked_add(1).expect("case count fits u32"); + } + assert!( + lowered >= 26, + "expected at least 26 shared cases lowered from facts, got {lowered}" + ); + assert_eq!( + skipped, + vec!["tolerant_unknown_kind".to_owned()], + "exactly the OD-2 (#294) snapshot is Python-only; changing this set is \ + a deliberate contract decision" + ); +} diff --git a/rust/crates/own-diagnostics/tests/dag.rs b/rust/crates/own-diagnostics/tests/dag.rs index eab21233..f3d788f4 100644 --- a/rust/crates/own-diagnostics/tests/dag.rs +++ b/rust/crates/own-diagnostics/tests/dag.rs @@ -40,6 +40,16 @@ fn allowed_edges() -> HashMap<&'static str, BTreeSet<&'static str>> { // never the reverse, and the surface must stay implementable without the // lowering that fills it. m.insert("own-lowered", BTreeSet::new()); + // The OwnIR -> Layer 2 lowering (#259 slice 3): a pure transformation + // crate. It READS the typed fact contract (own-ir) and CONSTRUCTS the + // typed Layer 2 surface (own-lowered) — both arrows point INTO data + // leaves, never the reverse, and own-lowered stays leaf (implementable + // without the lowering that fills it). No analysis/diagnostics edge: + // lowering must stay a pure OwnIr -> LoweredDocument function. + m.insert( + "own-bridge", + ["own-ir", "own-lowered"].into_iter().collect(), + ); // own-analysis CONSTRUCTS diagnostics and consumes the cfg lowering. It reads // the effect type through `own_cfg::Effect`, NOT the parser — so there is no // production own-syntax edge (own-syntax is a dev-only edge for its tests). diff --git a/rust/crates/own-lowered/tests/replay.rs b/rust/crates/own-lowered/tests/replay.rs index b43345c0..0b9336ae 100644 --- a/rust/crates/own-lowered/tests/replay.rs +++ b/rust/crates/own-lowered/tests/replay.rs @@ -55,6 +55,11 @@ fn replays_python_authored_goldens() { "duplicate manifest case name: {}", case.name ); + assert!( + !case.rules.is_empty() && case.rules.iter().all(|r| !r.is_empty()), + "{}: 'rules' must be a non-empty array of non-empty strings", + case.name + ); } let mut facts_files = BTreeSet::new(); let mut golden_files = BTreeSet::new(); @@ -109,8 +114,8 @@ fn replays_python_authored_goldens() { replayed = replayed.checked_add(1).expect("case count fits u32"); } assert!( - replayed >= 25, - "expected at least 25 shared cases, replayed {replayed}" + replayed >= 26, + "expected at least 26 shared cases, replayed {replayed}" ); assert_eq!( skipped, diff --git a/spec/Bridge.md b/spec/Bridge.md index f2d5e95d..d54f9238 100644 --- a/spec/Bridge.md +++ b/spec/Bridge.md @@ -169,7 +169,7 @@ depth ≥ 1; (2) its **shallowest** non-acquire reference is at depth 0; (3) it is not acquired anywhere inside a `while` body (loop acquires are cumulative — hoisting would hide a per-iteration leak); (4) the definite-assignment safety walk holds — no path can early-`return` (without returning the name) before -the post-merge release on a path that did not acquire it (an `if` establishes +the post-merge reference/discharge on a path that did not acquire it (an `if` establishes acquisition only when **both** arms do; a `while` body never does). The hoisted `Let` carries the **first** branch-acquire line and preserves the pool kind. An **untracked** name (BR-L8) is never hoisted. @@ -346,17 +346,26 @@ committed regeneration path and a zero-Python steady state: of the lowered `Module` (functions, params with regions, statement kinds with handles and lines, prelude/lifetime presence) per facts fixture — the seam where a lowering bug is visible *before* it hides behind a verdict. - **Built** (#259 foundation slice): `ownlang/lowered.py` is the Python - emitter (its docstring freezes the normalization decisions; - `LOWERED_VERSION` keys the surface), `tests/fixtures/lowered/ - .facts.json` + `.golden.json` are the committed pairs under the - frozen `manifest.json` ledger, and `tests/test_lowered_fixtures.py` is the - verify/`--write` harness (manifest == facts == goldens exactly; stale, - missing, orphaned, pair-deleted, and unlisted fixtures are each a red - build). In #259's implementation half Rust replays every - `rust_replay: true` manifest case byte-for-byte; a `rust_replay: false` - case is a Python-only snapshot pinning an open decision (OD-2/#294) and - takes no side on it. + **Built and Rust-implemented** (#259 slices 1–3): `ownlang/lowered.py` is + the authoritative Python emitter (#299 — its docstring freezes the + normalization decisions; `LOWERED_VERSION` keys the surface), + `tests/fixtures/lowered/.facts.json` + `.golden.json` are the + committed pairs under the frozen `manifest.json` ledger, and + `tests/test_lowered_fixtures.py` is the verify/`--write` harness + (manifest == facts == goldens exactly; stale, missing, orphaned, + pair-deleted, and unlisted fixtures are each a red build). On the Rust + side, `own-lowered` (#300) is the typed data surface + canonical emitter + that round-trips every shared golden byte-exactly (presence-aware + missing/null/value handle metadata; per-document `LOWERED_VERSION` + enforcement), and `own-bridge` (#301) **constructs** the Layer 2 document + from the facts themselves — `facts → own-ir parse → lower → canonical + emit` reproduces all 26 `rust_replay: true` goldens byte-for-byte with + the golden used only as expected output. A `rust_replay: false` case + (today exactly `tolerant_unknown_kind`) is a Python-only snapshot pinning + an open decision (OD-2/#294) and takes no side on it: the Rust bridge + fails loud on a present-but-unknown resource kind instead of adopting the + tolerant fallback. Layer 1, Layer 3, analysis wiring, and #259 as a whole + remain open. - **Layer 3 — final normalized diagnostics.** The findings list (and its SARIF/github/msbuild renderings) per facts fixture, byte-exact — the outer contract. Existing seeds: the end-to-end expectations in `test_ownir.py` diff --git a/spec/BridgeBehaviorMatrix.md b/spec/BridgeBehaviorMatrix.md index 8e6c85fe..862a3789 100644 --- a/spec/BridgeBehaviorMatrix.md +++ b/spec/BridgeBehaviorMatrix.md @@ -114,9 +114,14 @@ Every row above marked **L1/L2/L3/S** requires a same-layer Rust parity fixture in #259; rows marked *(core suite)* are `own-analysis`/`own-di` territory (the bridge only routes them — BR-B1) and are covered by those crates' own parity suites. Layer 2 (the normalized lowered representation) -is **built** (#259 foundation slice): `ownlang/lowered.py` + -`tests/fixtures/lowered/` + `tests/test_lowered_fixtures.py` give the +is **built and Rust-implemented** (#259 slices 1–3): `ownlang/lowered.py` + +`tests/fixtures/lowered/` + `tests/test_lowered_fixtures.py` (#299) give the (b)-section rows (and the MOS-sensitive lowering shapes of section (c)) a directly-pinned lowering surface in addition to their end-to-end (L3) -coverage; the per-case coverage of that fixture family is listed in the #259 -foundation PR. +coverage; `rust/crates/own-lowered` (#300) type-checks and re-emits every +shared golden byte-exactly, and `rust/crates/own-bridge` (#301) constructs +the same documents from the facts (`to_module` port: routing, minting, MOS, +flow lowering), reproducing all 26 `rust_replay: true` goldens byte-for-byte. +`tolerant_unknown_kind` stays the sole Python-only case pending #294. The +per-case coverage of the fixture family is listed in the #259 foundation PR; +Layer 1 and Layer 3 remain open. diff --git a/tests/test_lowered_fixtures.py b/tests/test_lowered_fixtures.py index 3a95d6e7..bbf1ce81 100644 --- a/tests/test_lowered_fixtures.py +++ b/tests/test_lowered_fixtures.py @@ -18,14 +18,16 @@ unlisted facts file are each a red build — the fixture family cannot silently rot (or shrink) in any direction. `--write` refuses to regenerate a shrunken contract for the same reason. -* The Rust `own-bridge` (#259) will replay every manifest case with - `rust_replay: true` from its `.facts.json` and must reproduce the - golden byte-for-byte; a `rust_replay: false` case is a Python-only behavior - snapshot pinning an open decision (its `decision` field names it, e.g. - OD-2/#294) and imposes nothing on Rust until that decision lands. Until - the Rust emitter exists this suite is the Python-side half of the contract - (zero-Python steady state: once Rust is authoritative, these goldens are - frozen inputs it replays without Python present). +* The Rust side holds up its half of the contract (#300/#301): `own-lowered` + parses and re-emits every shared golden byte-exactly through its typed + model, and `own-bridge` CONSTRUCTS the same documents from each + `rust_replay: true` case's `.facts.json`, reproducing the golden + byte-for-byte (`rust/crates/own-bridge/tests/replay.rs`). A + `rust_replay: false` case is a Python-only behavior snapshot pinning an + open decision (its `decision` field names it, e.g. OD-2/#294) and imposes + nothing on Rust until that decision lands. Python stays authoritative at + generation time (`--write`); the goldens are frozen inputs the Rust suites + verify without Python present (the zero-Python steady state). Run: python tests/test_lowered_fixtures.py (verify) python tests/test_lowered_fixtures.py --write (regenerate) @@ -68,6 +70,11 @@ def _manifest() -> tuple[list[str], list[str]]: continue if not isinstance(c.get("rust_replay"), bool): problems.append(f"manifest case '{name}': rust_replay must be a bool") + rules = c.get("rules") + if not (isinstance(rules, list) and rules + and all(isinstance(r, str) and r for r in rules)): + problems.append(f"manifest case '{name}': 'rules' must be a " + f"non-empty array of non-empty strings") if c.get("rust_replay") is False and not c.get("decision"): problems.append(f"manifest case '{name}': a Python-only case must " f"name the open decision it pins ('decision')")