check.rs (15627B)
1 use std::{collections::BTreeSet, fs, path::Path}; 2 3 use crate::{ 4 contracts::validate_sdk_contracts, 5 fs::workspace_root, 6 output::package_outputs, 7 package_matrix::{ 8 FORBIDDEN_PACKAGE_NAMES, WasmPackageSpec, package_specs, validate_package_matrix, 9 wasm_package_specs, 10 }, 11 }; 12 13 pub fn check() -> Result<(), String> { 14 validate_package_matrix()?; 15 let root = workspace_root()?; 16 validate_sdk_contracts(&root)?; 17 check_forbidden_packages(&root)?; 18 check_binding_crate_sources(&root)?; 19 for spec in package_specs() { 20 let package_dir = root.join(spec.package_dir); 21 let package_json_path = package_dir.join("package.json"); 22 let index_path = package_dir.join("src/index.ts"); 23 check_package_json(&package_json_path, spec.package_name)?; 24 if !index_path.is_file() { 25 return Err(format!("missing package index: {}", index_path.display())); 26 } 27 } 28 for spec in wasm_package_specs() { 29 check_wasm_package_surface(&root, *spec)?; 30 } 31 for output in package_outputs()? { 32 for expected in output.files() { 33 let path = root 34 .join(output.spec.package_dir) 35 .join(expected.relative_path); 36 let actual = fs::read_to_string(&path) 37 .map_err(|error| format!("failed to read {}: {error}", path.display()))?; 38 if actual != expected.contents { 39 return Err(format!("stale generated output: {}", path.display())); 40 } 41 } 42 } 43 Ok(()) 44 } 45 46 fn check_binding_crate_sources(root: &Path) -> Result<(), String> { 47 for spec in package_specs() { 48 let crate_src_dir = root.join(spec.crate_dir).join("src"); 49 let typescript_dir = crate_src_dir.join("typescript"); 50 if typescript_dir.exists() { 51 return Err(format!( 52 "forbidden crate TypeScript source directory exists: {}", 53 typescript_dir.display() 54 )); 55 } 56 check_no_typescript_files(&crate_src_dir)?; 57 } 58 for spec in wasm_package_specs() { 59 check_no_typescript_files(&root.join(spec.crate_dir).join("src"))?; 60 } 61 Ok(()) 62 } 63 64 fn check_no_typescript_files(dir: &Path) -> Result<(), String> { 65 for entry in 66 fs::read_dir(dir).map_err(|error| format!("failed to read {}: {error}", dir.display()))? 67 { 68 let entry = 69 entry.map_err(|error| format!("failed to read {} entry: {error}", dir.display()))?; 70 let path = entry.path(); 71 let file_type = entry 72 .file_type() 73 .map_err(|error| format!("failed to inspect {}: {error}", path.display()))?; 74 if file_type.is_dir() { 75 check_no_typescript_files(&path)?; 76 } else if file_type.is_file() 77 && path.extension().and_then(|extension| extension.to_str()) == Some("ts") 78 { 79 return Err(format!( 80 "forbidden crate TypeScript source file exists: {}", 81 path.display() 82 )); 83 } 84 } 85 Ok(()) 86 } 87 88 fn check_forbidden_packages(root: &Path) -> Result<(), String> { 89 for forbidden in FORBIDDEN_PACKAGE_NAMES { 90 let package_leaf = forbidden.trim_start_matches("@radroots/").to_owned(); 91 let forbidden_dir = root.join("packages").join(package_leaf); 92 if forbidden_dir.exists() { 93 return Err(format!( 94 "forbidden package directory exists: {}", 95 forbidden_dir.display() 96 )); 97 } 98 } 99 Ok(()) 100 } 101 102 fn check_package_json(path: &Path, expected_name: &str) -> Result<(), String> { 103 let raw = fs::read_to_string(path) 104 .map_err(|error| format!("failed to read {}: {error}", path.display()))?; 105 let json = serde_json::from_str::<serde_json::Value>(&raw) 106 .map_err(|error| format!("failed to parse {}: {error}", path.display()))?; 107 let actual_name = json 108 .get("name") 109 .and_then(serde_json::Value::as_str) 110 .ok_or_else(|| format!("package.json missing name: {}", path.display()))?; 111 if actual_name != expected_name { 112 return Err(format!( 113 "package name mismatch in {}: expected {expected_name}, found {actual_name}", 114 path.display() 115 )); 116 } 117 let private = json 118 .get("private") 119 .and_then(serde_json::Value::as_bool) 120 .unwrap_or(false); 121 if !private { 122 return Err(format!("package must be private: {}", path.display())); 123 } 124 Ok(()) 125 } 126 127 pub(crate) fn check_wasm_package_surface(root: &Path, spec: WasmPackageSpec) -> Result<(), String> { 128 let package_dir = root.join(spec.package_dir); 129 let package_json_path = package_dir.join("package.json"); 130 check_package_json(&package_json_path, spec.package_name)?; 131 let raw = fs::read_to_string(&package_json_path) 132 .map_err(|error| format!("failed to read {}: {error}", package_json_path.display()))?; 133 let json = serde_json::from_str::<serde_json::Value>(&raw) 134 .map_err(|error| format!("failed to parse {}: {error}", package_json_path.display()))?; 135 let dist_manifest = package_dir.join("dist").join("package.json"); 136 if dist_manifest.exists() { 137 return Err(format!( 138 "generated package manifest is forbidden: {}", 139 dist_manifest.display() 140 )); 141 } 142 for relative in package_surface_paths(&json, &package_json_path)? { 143 let normalized = relative.trim_start_matches("./"); 144 let path = package_dir.join(normalized); 145 if !path.is_file() { 146 return Err(format!( 147 "missing package export artifact for {}: {}", 148 spec.package_name, 149 path.display() 150 )); 151 } 152 } 153 Ok(()) 154 } 155 156 fn package_surface_paths( 157 json: &serde_json::Value, 158 package_json_path: &Path, 159 ) -> Result<BTreeSet<String>, String> { 160 let mut paths = BTreeSet::new(); 161 collect_required_package_path(json, package_json_path, "main", &mut paths)?; 162 collect_required_package_path(json, package_json_path, "types", &mut paths)?; 163 let exports = json.get("exports").ok_or_else(|| { 164 format!( 165 "package.json missing exports: {}", 166 package_json_path.display() 167 ) 168 })?; 169 match exports { 170 serde_json::Value::String(path) => { 171 validate_package_surface_path(path, package_json_path, "exports")?; 172 paths.insert(path.clone()); 173 } 174 serde_json::Value::Object(map) => { 175 if map.keys().any(|key| key != ".") { 176 return Err(format!( 177 "package.json only supports root exports: {}", 178 package_json_path.display() 179 )); 180 } 181 let root_export = map.get(".").ok_or_else(|| { 182 format!( 183 "package.json missing root export: {}", 184 package_json_path.display() 185 ) 186 })?; 187 collect_export_paths(root_export, package_json_path, "exports[\".\"]", &mut paths)?; 188 } 189 _ => { 190 return Err(format!( 191 "package.json exports must be a string or object: {}", 192 package_json_path.display() 193 )); 194 } 195 } 196 Ok(paths) 197 } 198 199 fn collect_required_package_path( 200 json: &serde_json::Value, 201 package_json_path: &Path, 202 field: &'static str, 203 paths: &mut BTreeSet<String>, 204 ) -> Result<(), String> { 205 let value = json 206 .get(field) 207 .and_then(serde_json::Value::as_str) 208 .ok_or_else(|| { 209 format!( 210 "package.json missing {field}: {}", 211 package_json_path.display() 212 ) 213 })?; 214 validate_package_surface_path(value, package_json_path, field)?; 215 paths.insert(value.to_owned()); 216 Ok(()) 217 } 218 219 fn collect_export_paths( 220 value: &serde_json::Value, 221 package_json_path: &Path, 222 field: &str, 223 paths: &mut BTreeSet<String>, 224 ) -> Result<(), String> { 225 match value { 226 serde_json::Value::String(path) => { 227 validate_package_surface_path(path, package_json_path, field)?; 228 paths.insert(path.clone()); 229 Ok(()) 230 } 231 serde_json::Value::Object(map) => { 232 for (key, value) in map { 233 collect_export_paths(value, package_json_path, &format!("{field}.{key}"), paths)?; 234 } 235 Ok(()) 236 } 237 _ => Err(format!( 238 "package.json {field} must name file paths: {}", 239 package_json_path.display() 240 )), 241 } 242 } 243 244 fn validate_package_surface_path( 245 value: &str, 246 package_json_path: &Path, 247 field: &str, 248 ) -> Result<(), String> { 249 if value.trim().is_empty() 250 || value.trim() != value 251 || !value.starts_with("./dist/") 252 || value.contains('\\') 253 || value.split('/').any(|segment| segment == "..") 254 { 255 return Err(format!( 256 "package.json {field} must be a relative dist path: {}", 257 package_json_path.display() 258 )); 259 } 260 Ok(()) 261 } 262 263 #[cfg(test)] 264 mod tests { 265 use std::{ 266 fs, 267 path::PathBuf, 268 time::{SystemTime, UNIX_EPOCH}, 269 }; 270 271 use crate::package_matrix::{WasmPackageSpec, validate_package_matrix}; 272 273 use super::{ 274 check_binding_crate_sources, check_no_typescript_files, check_wasm_package_surface, 275 }; 276 277 #[test] 278 fn package_skeleton_is_valid() { 279 validate_package_matrix().expect("package matrix validates"); 280 } 281 282 #[test] 283 fn rejects_crate_typescript_directories() { 284 let root = test_root("typescript_dir"); 285 let typescript_dir = root 286 .join("crates") 287 .join("core_bindings") 288 .join("src") 289 .join("typescript"); 290 fs::create_dir_all(&typescript_dir).expect("create forbidden directory"); 291 292 let error = check_binding_crate_sources(&root).expect_err("forbidden directory rejected"); 293 294 assert!(error.contains("forbidden crate TypeScript source directory")); 295 let _ = fs::remove_dir_all(root); 296 } 297 298 #[test] 299 fn rejects_crate_typescript_files() { 300 let root = test_root("typescript_file"); 301 let src_dir = root.join("crates/core_bindings/src"); 302 fs::create_dir_all(&src_dir).expect("create crate source directory"); 303 fs::write(src_dir.join("types.ts"), "export type A = string;\n") 304 .expect("write forbidden file"); 305 306 let error = check_no_typescript_files(&src_dir).expect_err("forbidden file rejected"); 307 308 assert!(error.contains("forbidden crate TypeScript source file")); 309 let _ = fs::remove_dir_all(root); 310 } 311 312 #[test] 313 fn wasm_package_surface_requires_exported_dist_files() { 314 let root = test_root("wasm_surface"); 315 let package_dir = root.join("packages").join("example-wasm"); 316 fs::create_dir_all(package_dir.join("dist")).expect("create dist"); 317 fs::write( 318 package_dir.join("package.json"), 319 r#"{ 320 "name": "@radroots/example-wasm", 321 "private": true, 322 "main": "./dist/example.js", 323 "types": "./dist/example.d.ts", 324 "exports": { 325 ".": { 326 "types": "./dist/example.d.ts", 327 "import": "./dist/example.js", 328 "default": "./dist/example.js" 329 } 330 } 331 }"#, 332 ) 333 .expect("write package json"); 334 fs::write(package_dir.join("dist").join("example.js"), "export {};\n").expect("write js"); 335 let spec = WasmPackageSpec { 336 key: "example", 337 crate_name: "radroots_example_wasm", 338 crate_dir: "crates/example_wasm", 339 package_name: "@radroots/example-wasm", 340 package_dir: "packages/example-wasm", 341 out_name: "example", 342 out_dir: "../../packages/example-wasm/dist", 343 }; 344 345 let missing = 346 check_wasm_package_surface(&root, spec).expect_err("missing d.ts should fail"); 347 assert!(missing.contains("example.d.ts")); 348 fs::write( 349 package_dir.join("dist").join("example.d.ts"), 350 "export {};\n", 351 ) 352 .expect("write d.ts"); 353 check_wasm_package_surface(&root, spec).expect("surface is complete"); 354 355 let _ = fs::remove_dir_all(root); 356 } 357 358 #[test] 359 fn wasm_package_surface_rejects_generated_package_manifest() { 360 let root = test_root("wasm_dist_package_manifest"); 361 let package_dir = root.join("packages").join("example-wasm"); 362 fs::create_dir_all(package_dir.join("dist")).expect("create dist"); 363 fs::write( 364 package_dir.join("package.json"), 365 r#"{ 366 "name": "@radroots/example-wasm", 367 "private": true, 368 "main": "./dist/example.js", 369 "types": "./dist/example.d.ts", 370 "exports": "./dist/example.js" 371 }"#, 372 ) 373 .expect("write package json"); 374 fs::write(package_dir.join("dist").join("example.js"), "export {};\n").expect("write js"); 375 fs::write( 376 package_dir.join("dist").join("example.d.ts"), 377 "export {};\n", 378 ) 379 .expect("write d.ts"); 380 fs::write(package_dir.join("dist").join("package.json"), "{}\n") 381 .expect("write forbidden manifest"); 382 let spec = WasmPackageSpec { 383 key: "example", 384 crate_name: "radroots_example_wasm", 385 crate_dir: "crates/example_wasm", 386 package_name: "@radroots/example-wasm", 387 package_dir: "packages/example-wasm", 388 out_name: "example", 389 out_dir: "../../packages/example-wasm/dist", 390 }; 391 392 let error = 393 check_wasm_package_surface(&root, spec).expect_err("dist package manifest rejected"); 394 assert!(error.contains("generated package manifest is forbidden")); 395 396 let _ = fs::remove_dir_all(root); 397 } 398 399 #[test] 400 fn wasm_package_surface_rejects_subpath_exports() { 401 let root = test_root("wasm_subpath_exports"); 402 let package_dir = root.join("packages").join("example-wasm"); 403 fs::create_dir_all(package_dir.join("dist")).expect("create dist"); 404 fs::write( 405 package_dir.join("package.json"), 406 r#"{ 407 "name": "@radroots/example-wasm", 408 "private": true, 409 "main": "./dist/example.js", 410 "types": "./dist/example.d.ts", 411 "exports": { 412 ".": "./dist/example.js", 413 "./extra": "./dist/extra.js" 414 } 415 }"#, 416 ) 417 .expect("write package json"); 418 fs::write(package_dir.join("dist").join("example.js"), "export {};\n").expect("write js"); 419 fs::write( 420 package_dir.join("dist").join("example.d.ts"), 421 "export {};\n", 422 ) 423 .expect("write d.ts"); 424 let spec = WasmPackageSpec { 425 key: "example", 426 crate_name: "radroots_example_wasm", 427 crate_dir: "crates/example_wasm", 428 package_name: "@radroots/example-wasm", 429 package_dir: "packages/example-wasm", 430 out_name: "example", 431 out_dir: "../../packages/example-wasm/dist", 432 }; 433 434 let error = check_wasm_package_surface(&root, spec).expect_err("subpath export rejected"); 435 assert!(error.contains("only supports root exports")); 436 437 let _ = fs::remove_dir_all(root); 438 } 439 440 fn test_root(name: &str) -> PathBuf { 441 let stamp = SystemTime::now() 442 .duration_since(UNIX_EPOCH) 443 .expect("system time after epoch") 444 .as_nanos(); 445 let root = std::env::temp_dir().join(format!( 446 "radroots_sdk_xtask_check_{name}_{}_{}", 447 std::process::id(), 448 stamp 449 )); 450 let _ = fs::remove_dir_all(&root); 451 root 452 } 453 }