|
| 1 | +//! Spec §3/§9 — native boundary lowering and bindings |
| 2 | +#![allow(unused_imports, dead_code)] |
| 3 | +use super::*; |
| 4 | + |
| 5 | +#[test] |
| 6 | +fn rust_lowering_maps_native_call_boundaries() { |
| 7 | + let source = r#" |
| 8 | +features: native |
| 9 | +
|
| 10 | +native fn host_emit(message: read String) -> Unit |
| 11 | + effects(native) |
| 12 | +
|
| 13 | +pub fn run() -> Unit { |
| 14 | + host_emit(message: read "host") |
| 15 | + Log.write(message: read "core") |
| 16 | +} |
| 17 | +"#; |
| 18 | + let package = lower_sources_to_rust_package_with_options( |
| 19 | + &[("native.rss".to_string(), source.to_string())], |
| 20 | + "Native Example.rss", |
| 21 | + "/workspace/rsscript/runtime", |
| 22 | + &[], |
| 23 | + &[NativeRustDependency { |
| 24 | + crate_name: "host_native".to_string(), |
| 25 | + path: "/workspace/host-native".to_string(), |
| 26 | + cargo_features: Vec::new(), |
| 27 | + bindings: BTreeMap::from([("host_emit".to_string(), "host_native::emit".to_string())]), |
| 28 | + }], |
| 29 | + ) |
| 30 | + .expect("source should lower with native binding"); |
| 31 | + let source_map: Vec<rsscript::RustSourceMapEntry> = |
| 32 | + serde_json::from_str(&package.source_map_json).expect("source map should parse"); |
| 33 | + let native_calls = source_map |
| 34 | + .iter() |
| 35 | + .filter(|entry| entry.kind == "native_call") |
| 36 | + .collect::<Vec<_>>(); |
| 37 | + |
| 38 | + assert_eq!(native_calls.len(), 2); |
| 39 | + assert!(native_calls.iter().any(|entry| entry.source.line == 8)); |
| 40 | + assert!(native_calls.iter().any(|entry| entry.source.line == 9)); |
| 41 | + assert!( |
| 42 | + package |
| 43 | + .lib_rs |
| 44 | + .contains("host_native::emit(&(\"host\".to_string()));") |
| 45 | + ); |
| 46 | +} |
| 47 | + |
| 48 | +#[test] |
| 49 | +fn rust_lowering_maps_receiver_native_binding_with_receiver_argument() { |
| 50 | + let source = r#" |
| 51 | +features: native |
| 52 | +
|
| 53 | +opaque struct Alpha |
| 54 | +
|
| 55 | +native fn Alpha.open() -> Alpha |
| 56 | + effects(native) |
| 57 | +
|
| 58 | +native fn Alpha.describe(self: read Alpha) -> String |
| 59 | + effects(native) |
| 60 | +
|
| 61 | +pub fn run() -> Unit { |
| 62 | + let alpha = Alpha.open() |
| 63 | + Log.write(message: read alpha.describe()) |
| 64 | +} |
| 65 | +"#; |
| 66 | + let package = lower_sources_to_rust_package_with_options( |
| 67 | + &[("receiver-native.rss".to_string(), source.to_string())], |
| 68 | + "Receiver Native Example.rss", |
| 69 | + "/workspace/rsscript/runtime", |
| 70 | + &[], |
| 71 | + &[NativeRustDependency { |
| 72 | + crate_name: "alpha_native".to_string(), |
| 73 | + path: "/workspace/alpha-native".to_string(), |
| 74 | + cargo_features: Vec::new(), |
| 75 | + bindings: BTreeMap::from([ |
| 76 | + ("Alpha.open".to_string(), "alpha_native::open".to_string()), |
| 77 | + ( |
| 78 | + "Alpha.describe".to_string(), |
| 79 | + "alpha_native::describe".to_string(), |
| 80 | + ), |
| 81 | + ]), |
| 82 | + }], |
| 83 | + ) |
| 84 | + .expect("source should lower with receiver native binding"); |
| 85 | + |
| 86 | + assert!( |
| 87 | + package.lib_rs.contains("alpha_native::open()"), |
| 88 | + "qualified native call should use bound target, got:\n{}", |
| 89 | + package.lib_rs |
| 90 | + ); |
| 91 | + assert!( |
| 92 | + package.lib_rs.contains("alpha_native::describe(&alpha)"), |
| 93 | + "receiver native call should pass receiver as first argument to bound target, got:\n{}", |
| 94 | + package.lib_rs |
| 95 | + ); |
| 96 | + assert!( |
| 97 | + !package.lib_rs.contains("Alpha::describe(&alpha)"), |
| 98 | + "receiver native binding should not fall back to generated qualified call, got:\n{}", |
| 99 | + package.lib_rs |
| 100 | + ); |
| 101 | +} |
| 102 | + |
| 103 | +#[test] |
| 104 | +fn rust_lowering_can_emit_native_wrapper_path_dependencies() { |
| 105 | + let source = r#" |
| 106 | +fn main() -> Unit { |
| 107 | + return Unit |
| 108 | +} |
| 109 | +"#; |
| 110 | + let package = lower_sources_to_rust_package_with_options( |
| 111 | + &[("main.rss".to_string(), source.to_string())], |
| 112 | + "Native Example.rss", |
| 113 | + "/workspace/rsscript/runtime", |
| 114 | + &[], |
| 115 | + &[NativeRustDependency { |
| 116 | + crate_name: "rss_json_native".to_string(), |
| 117 | + path: "/workspace/rss-json/native/rust".to_string(), |
| 118 | + cargo_features: Vec::new(), |
| 119 | + bindings: BTreeMap::new(), |
| 120 | + }], |
| 121 | + ) |
| 122 | + .expect("source should lower into package with native dependency"); |
| 123 | + |
| 124 | + assert!( |
| 125 | + package |
| 126 | + .cargo_toml |
| 127 | + .contains("\"rss_json_native\" = { path = \"/workspace/rss-json/native/rust\" }") |
| 128 | + ); |
| 129 | +} |
| 130 | + |
| 131 | +#[test] |
| 132 | +fn checker_reports_malformed_bindings_and_arguments_as_unsupported() { |
| 133 | + let source = r#" |
| 134 | +fn main() -> Unit { |
| 135 | + let missing = |
| 136 | + print(value:) |
| 137 | +} |
| 138 | +"#; |
| 139 | + let diagnostics = analyze_source("malformed-body.rss", source); |
| 140 | + |
| 141 | + assert!( |
| 142 | + diagnostics |
| 143 | + .iter() |
| 144 | + .any(|diagnostic| diagnostic.code == "RS0015" |
| 145 | + && diagnostic.label == "malformed statement") |
| 146 | + ); |
| 147 | + assert!( |
| 148 | + diagnostics |
| 149 | + .iter() |
| 150 | + .any(|diagnostic| diagnostic.code == "RS0015" |
| 151 | + && diagnostic.label == "unsupported expression") |
| 152 | + ); |
| 153 | +} |
| 154 | + |
| 155 | +#[test] |
| 156 | +fn review_reports_new_unsafe_native_usage() { |
| 157 | + let old_source = r#" |
| 158 | +
|
| 159 | +fn checksum(data: read Bytes) -> UInt64 |
| 160 | + effects(no_panic) |
| 161 | +{ |
| 162 | + Bytes.checksum(data: read data) |
| 163 | +} |
| 164 | +"#; |
| 165 | + let new_source = r#" |
| 166 | +
|
| 167 | +fn checksum(data: read Bytes) -> UInt64 |
| 168 | + effects(no_panic, unsafe, native) |
| 169 | +{ |
| 170 | + Native.checksum(data: read data) |
| 171 | +} |
| 172 | +"#; |
| 173 | + |
| 174 | + let findings = review_sources("old.rss", old_source, "new.rss", new_source); |
| 175 | + let unsafe_finding = findings |
| 176 | + .iter() |
| 177 | + .find(|finding| finding.code == "RSR012") |
| 178 | + .expect("expected unsafe review finding"); |
| 179 | + let native_finding = findings |
| 180 | + .iter() |
| 181 | + .find(|finding| finding.code == "RSR015") |
| 182 | + .expect("expected native review finding"); |
| 183 | + |
| 184 | + assert_eq!(unsafe_finding.risk, ReviewRisk::Unsafe); |
| 185 | + assert_eq!(unsafe_finding.before.as_deref(), Some("<none>")); |
| 186 | + assert_eq!(unsafe_finding.after.as_deref(), Some("unsafe")); |
| 187 | + assert_eq!(native_finding.risk, ReviewRisk::Boundary); |
| 188 | + assert_eq!(native_finding.before.as_deref(), Some("<none>")); |
| 189 | + assert_eq!(native_finding.after.as_deref(), Some("native")); |
| 190 | + assert!( |
| 191 | + format_review_human(&findings) |
| 192 | + .contains("RSR012[unsafe]: function `checksum` added unsafe boundary.") |
| 193 | + ); |
| 194 | + assert!( |
| 195 | + format_review_human(&findings) |
| 196 | + .contains("RSR015[boundary]: function `checksum` added native boundary.") |
| 197 | + ); |
| 198 | + |
| 199 | + let json = format_review_json(&findings); |
| 200 | + let value: Value = serde_json::from_str(&json).expect("review JSON should parse"); |
| 201 | + assert!(value.as_array().is_some_and(|items| { |
| 202 | + items |
| 203 | + .iter() |
| 204 | + .any(|item| item["code"] == "RSR012" && item["risk"] == "unsafe") |
| 205 | + })); |
| 206 | + assert!(value.as_array().is_some_and(|items| { |
| 207 | + items |
| 208 | + .iter() |
| 209 | + .any(|item| item["code"] == "RSR015" && item["risk"] == "boundary") |
| 210 | + })); |
| 211 | +} |
| 212 | + |
| 213 | +#[test] |
| 214 | +fn review_reports_native_fn_as_native_boundary() { |
| 215 | + let old_source = r#" |
| 216 | +fn host_emit(message: read String) -> Unit |
| 217 | +{ |
| 218 | + Log.write(message: read message) |
| 219 | +} |
| 220 | +"#; |
| 221 | + let new_source = r#" |
| 222 | +native fn host_emit(message: read String) -> Unit |
| 223 | + effects(native) |
| 224 | +"#; |
| 225 | + |
| 226 | + let findings = review_sources("old.rss", old_source, "new.rss", new_source); |
| 227 | + let native_finding = findings |
| 228 | + .iter() |
| 229 | + .find(|finding| finding.code == "RSR015") |
| 230 | + .expect("expected native boundary review finding"); |
| 231 | + |
| 232 | + assert_eq!(native_finding.risk, ReviewRisk::Boundary); |
| 233 | + assert_eq!(native_finding.before.as_deref(), Some("<none>")); |
| 234 | + assert_eq!(native_finding.after.as_deref(), Some("native")); |
| 235 | + assert!(!findings.iter().any(|finding| finding.code == "RSR012")); |
| 236 | +} |
| 237 | + |
| 238 | +#[test] |
| 239 | +fn review_map_marks_native_calls_inside_noescape_callbacks() { |
| 240 | + let source = r#" |
| 241 | +features: native |
| 242 | +
|
| 243 | +native fn Native.echo(message: read String) -> String |
| 244 | + effects(native) |
| 245 | +
|
| 246 | +fn apply(callback: noescape Fn()) -> Unit { |
| 247 | + callback() |
| 248 | + return Unit |
| 249 | +} |
| 250 | +
|
| 251 | +fn caller(message: read String) -> Unit { |
| 252 | + apply(callback: || { |
| 253 | + Native.echo(message: read message) |
| 254 | + }) |
| 255 | + return Unit |
| 256 | +} |
| 257 | +"#; |
| 258 | + let map = review_map_sources(vec![("callback-native.rss", source)]); |
| 259 | + let region = map.files[0] |
| 260 | + .regions |
| 261 | + .iter() |
| 262 | + .find(|region| region.function == "caller") |
| 263 | + .expect("expected caller region"); |
| 264 | + |
| 265 | + assert_eq!( |
| 266 | + region.classification, |
| 267 | + ReviewMapClassification::ReviewRequired |
| 268 | + ); |
| 269 | + assert!( |
| 270 | + region |
| 271 | + .reasons |
| 272 | + .iter() |
| 273 | + .any(|reason| reason == "native call `Native.echo`"), |
| 274 | + "{region:?}" |
| 275 | + ); |
| 276 | +} |
| 277 | + |
| 278 | +#[test] |
| 279 | +fn review_map_selfhost_classifier_has_no_unknown_regions() { |
| 280 | + let path = Path::new("tests/fixtures/pass/selfhost-review-classifier.rss"); |
| 281 | + let source = common::read_fixture(path); |
| 282 | + let map = review_map_sources(vec![(path.to_str().unwrap(), source.as_str())]); |
| 283 | + |
| 284 | + assert!(map.summary.total_functions >= 40, "{map:?}"); |
| 285 | + assert!(map.summary.total_lines >= 622, "{map:?}"); |
| 286 | + assert_eq!(map.files[0].risk, ReviewMapFileRisk::Elevated); |
| 287 | + assert_eq!(map.summary.unknown.functions, 0, "{map:?}"); |
| 288 | + assert_eq!(map.summary.unknown.lines, 0, "{map:?}"); |
| 289 | + assert!(map.summary.review_required.functions >= 27, "{map:?}"); |
| 290 | + |
| 291 | + let json: Value = |
| 292 | + serde_json::from_str(&format_review_map_json(&map)).expect("review map JSON should parse"); |
| 293 | + assert_eq!(json["summary"]["unknown_ratio"], 0.0); |
| 294 | + assert_eq!(json["summary"]["unknown_function_ratio"], 0.0); |
| 295 | + assert!( |
| 296 | + json["summary"]["must_review"]["functions"] |
| 297 | + .as_u64() |
| 298 | + .is_some_and(|count| count >= 27) |
| 299 | + ); |
| 300 | + assert!( |
| 301 | + json["summary"]["low_semantic_risk"]["functions"] |
| 302 | + .as_u64() |
| 303 | + .is_some_and(|count| count >= 13) |
| 304 | + ); |
| 305 | +} |
| 306 | + |
| 307 | +#[test] |
| 308 | +fn parser_expands_native_module_declarations_to_native_functions() { |
| 309 | + let source = r#" |
| 310 | +features: native |
| 311 | +
|
| 312 | +struct Path |
| 313 | +resource File |
| 314 | +struct IOError |
| 315 | +
|
| 316 | +native module File { |
| 317 | + fn open(path: read Path) -> Result<File, IOError> |
| 318 | +} |
| 319 | +"#; |
| 320 | + let diagnostics = analyze_source("native-module.rss", source); |
| 321 | + assert_eq!(diagnostics, Vec::new()); |
| 322 | + |
| 323 | + let map = review_map_sources(vec![("native-module.rss", source)]); |
| 324 | + assert!(map.files[0].regions.iter().any(|region| { |
| 325 | + region |
| 326 | + .reasons |
| 327 | + .iter() |
| 328 | + .any(|reason| reason == "native boundary") |
| 329 | + })); |
| 330 | +} |
0 commit comments