hygiene.rs (15724B)
1 use std::fs; 2 use std::path::{Path, PathBuf}; 3 4 const BINDING_DEPENDENCIES: &[&str] = &[ 5 "serde-wasm-bindgen", 6 "ts-rs", 7 "typeshare", 8 "uniffi", 9 "uniffi-build", 10 "uniffi_build", 11 "wasm-bindgen", 12 "wasm-bindgen-futures", 13 "wasm-bindgen-test", 14 ]; 15 16 pub fn run(args: &[String], root: &Path) -> Result<(), String> { 17 match args.first().map(String::as_str) { 18 Some("forbidden-identifiers") => validate_forbidden_identifiers(root), 19 _ => Err("unknown hygiene subcommand".to_string()), 20 } 21 } 22 23 pub fn validate_forbidden_identifiers(root: &Path) -> Result<(), String> { 24 let mut failures = Vec::new(); 25 reject_substrings( 26 root, 27 &[PathBuf::from("crates/relay_transport/src")], 28 &["RadrootsEventIngest::verified"], 29 "relay fetch must not bypass event-store verification", 30 &[], 31 &mut failures, 32 ); 33 reject_substrings( 34 root, 35 &[PathBuf::from("crates/event_store/src")], 36 &["last_created_at", "last_event_id"], 37 "event-store projection cursors must use last_event_seq", 38 &[], 39 &mut failures, 40 ); 41 reject_raw_protocol_strings(root, &mut failures); 42 reject_substrings( 43 root, 44 &[ 45 PathBuf::from("crates/events/src"), 46 PathBuf::from("crates/events_codec/src"), 47 PathBuf::from("crates/trade/src"), 48 ], 49 &[ 50 "RadrootsTradeMessageType", 51 "RadrootsTradeEnvelope", 52 "RadrootsTradeMessagePayload", 53 "RadrootsTradeQuestion", 54 "RadrootsTradeAnswer", 55 "RadrootsTradeDiscount", 56 "RadrootsTradeOrder", 57 "RadrootsActiveOrder", 58 "RadrootsActiveTrade", 59 "RadrootsTradeListingParseError", 60 "RadrootsTradeDomain", 61 "radroots_sdk::trade::", 62 "TradeListingParseError", 63 "TradeListingEnvelope", 64 "TradeListingMessage", 65 "KIND_TRADE_ORDER", 66 "TRADE_LISTING_KINDS", 67 "build_envelope_draft", 68 "parse_envelope", 69 "public_trade", 70 "events::trade::", 71 "events_codec::trade::", 72 "trade_order_economics_digest", 73 "trade_revision", 74 "trade_lifecycle", 75 "reduce_active_order", 76 "canonicalize_active_order", 77 "active_trade_", 78 "ActiveOrder", 79 "active_order", 80 "active order", 81 "active trade", 82 "RADROOTS_TRADE_LISTING_DOMAIN", 83 "RADROOTS_TRADE_ENVELOPE_VERSION", 84 ], 85 "legacy trade identifiers must not reappear", 86 &[], 87 &mut failures, 88 ); 89 reject_substrings( 90 root, 91 &[PathBuf::from("crates"), PathBuf::from("contracts")], 92 &[ 93 "KIND_TRADE_LISTING_ORDER", 94 "KIND_TRADE_LISTING_QUESTION", 95 "KIND_TRADE_LISTING_ANSWER", 96 "KIND_TRADE_LISTING_DISCOUNT", 97 "KIND_TRADE_LISTING_CANCEL", 98 "KIND_TRADE_LISTING_FULFILLMENT", 99 "KIND_TRADE_LISTING_RECEIPT", 100 ], 101 "legacy trade listing kind constants must not reappear", 102 &[], 103 &mut failures, 104 ); 105 reject_substrings( 106 root, 107 &[ 108 PathBuf::from("crates"), 109 PathBuf::from("contracts"), 110 PathBuf::from("tools"), 111 PathBuf::from("build"), 112 ], 113 &["tangle"], 114 "legacy identifier 'tangle' must not reappear", 115 &["tools/xtask/src/hygiene.rs"], 116 &mut failures, 117 ); 118 reject_binding_dependencies(root, &mut failures); 119 reject_forbidden_crate_paths(root, &mut failures); 120 reject_existing_paths( 121 root, 122 &[ 123 "spec", 124 "policy", 125 "nix", 126 "scripts", 127 "bindings", 128 "dist", 129 "ffi", 130 "generated", 131 "packages", 132 "pkg", 133 "contracts/exports", 134 "contracts/language-exports", 135 "contracts/language-exports.toml", 136 "contracts/language_exports", 137 "contracts/language_exports.toml", 138 "contracts/package-matrix", 139 "contracts/package-matrix.toml", 140 "contracts/package_matrix", 141 "contracts/package_matrix.toml", 142 "contracts/sdk-exports", 143 "contracts/sdk_exports", 144 "spec/exports", 145 "spec/sdk-exports", 146 ], 147 "SDK, binding, generated-package, and retired layout paths must stay outside rr-rs", 148 &mut failures, 149 ); 150 151 if failures.is_empty() { 152 println!("forbidden identifier hygiene passed"); 153 Ok(()) 154 } else { 155 Err(format!( 156 "forbidden identifier hygiene violations:\n{}", 157 failures.join("\n") 158 )) 159 } 160 } 161 162 fn reject_binding_dependencies(root: &Path, failures: &mut Vec<String>) { 163 for file in manifest_files(root) { 164 let rel = display_path(root, &file); 165 let Ok(content) = fs::read_to_string(&file) else { 166 continue; 167 }; 168 let Ok(manifest) = content.parse::<toml::Value>() else { 169 failures.push(format!("Cargo manifest must parse as TOML: {rel}")); 170 continue; 171 }; 172 reject_binding_dependencies_in_value(&manifest, &mut Vec::new(), &rel, failures); 173 } 174 } 175 176 fn reject_binding_dependencies_in_value( 177 value: &toml::Value, 178 path: &mut Vec<String>, 179 manifest_rel: &str, 180 failures: &mut Vec<String>, 181 ) { 182 let Some(table) = value.as_table() else { 183 return; 184 }; 185 if path 186 .last() 187 .is_some_and(|segment| is_dependency_table_name(segment)) 188 { 189 for dependency in BINDING_DEPENDENCIES { 190 if table.contains_key(*dependency) { 191 failures.push(format!( 192 "SDK, FFI, binding, and generated-package dependencies are forbidden in rr-rs: {manifest_rel}: {dependency} in [{}]", 193 path.join(".") 194 )); 195 } 196 } 197 } 198 for (key, child) in table { 199 path.push(key.clone()); 200 reject_binding_dependencies_in_value(child, path, manifest_rel, failures); 201 path.pop(); 202 } 203 } 204 205 fn is_dependency_table_name(segment: &str) -> bool { 206 matches!( 207 segment, 208 "dependencies" | "dev-dependencies" | "build-dependencies" 209 ) 210 } 211 212 fn manifest_files(root: &Path) -> Vec<PathBuf> { 213 let mut files = vec![root.join("Cargo.toml")]; 214 files.extend(files_under( 215 root, 216 &[PathBuf::from("crates"), PathBuf::from("tools")], 217 )); 218 files.retain(|path| path.file_name().and_then(|name| name.to_str()) == Some("Cargo.toml")); 219 files.sort(); 220 files.dedup(); 221 files 222 } 223 224 fn reject_forbidden_crate_paths(root: &Path, failures: &mut Vec<String>) { 225 let Ok(entries) = fs::read_dir(root.join("crates")) else { 226 return; 227 }; 228 for entry in entries.flatten() { 229 let path = entry.path(); 230 if !path.is_dir() { 231 continue; 232 } 233 let name = entry.file_name().to_string_lossy().to_string(); 234 if is_forbidden_crate_dir_name(&name) { 235 failures.push(format!( 236 "SDK, FFI, binding, and generated-package crate paths are forbidden in rr-rs: crates/{name}" 237 )); 238 } 239 } 240 } 241 242 fn is_forbidden_crate_dir_name(name: &str) -> bool { 243 let lowercase = name.to_ascii_lowercase(); 244 lowercase.contains("ffi") || lowercase.contains("binding") || lowercase.contains("_wasm") 245 } 246 247 fn reject_existing_paths(root: &Path, rel_paths: &[&str], label: &str, failures: &mut Vec<String>) { 248 for rel_path in rel_paths { 249 if root.join(rel_path).exists() { 250 failures.push(format!("{label}: {rel_path}")); 251 } 252 } 253 } 254 255 fn reject_substrings( 256 root: &Path, 257 rel_roots: &[PathBuf], 258 patterns: &[&str], 259 label: &str, 260 ignored_rel_paths: &[&str], 261 failures: &mut Vec<String>, 262 ) { 263 for file in files_under(root, rel_roots) { 264 let rel = display_path(root, &file); 265 if ignored_rel_paths.contains(&rel.as_str()) { 266 continue; 267 } 268 let Ok(content) = fs::read_to_string(&file) else { 269 continue; 270 }; 271 for (line_index, line) in content.lines().enumerate() { 272 for pattern in patterns { 273 if line.contains(pattern) { 274 failures.push(format!( 275 "{label}: {}:{}: {}", 276 rel, 277 line_index + 1, 278 line.trim() 279 )); 280 } 281 } 282 } 283 } 284 } 285 286 fn reject_raw_protocol_strings(root: &Path, failures: &mut Vec<String>) { 287 let rel_roots = [ 288 PathBuf::from("crates/events/src"), 289 PathBuf::from("crates/events_codec/src"), 290 PathBuf::from("crates/trade/src"), 291 ]; 292 for file in files_under(root, &rel_roots) { 293 let Ok(content) = fs::read_to_string(&file) else { 294 continue; 295 }; 296 let mut struct_name = String::new(); 297 for (line_index, line) in content.lines().enumerate() { 298 let trimmed = line.trim(); 299 if let Some(rest) = trimmed.strip_prefix("pub struct ") { 300 struct_name = rest 301 .split(['<', '{', ' ', '(']) 302 .next() 303 .unwrap_or_default() 304 .to_owned(); 305 } 306 if trimmed == "}" { 307 struct_name.clear(); 308 } 309 if is_raw_protocol_field(trimmed) && !is_allowed_raw_boundary(&struct_name) { 310 failures.push(format!( 311 "raw commercial protocol identifier String fields are forbidden: {}:{}: {}", 312 display_path(root, &file), 313 line_index + 1, 314 trimmed 315 )); 316 } 317 } 318 } 319 } 320 321 fn is_raw_protocol_field(line: &str) -> bool { 322 [ 323 "pub order_id: String,", 324 "pub listing_addr: String,", 325 "pub revision_id: String,", 326 "pub quote_id: String,", 327 "pub primary_bin_id: String,", 328 "pub bin_id: String,", 329 "pub economics_digest: String,", 330 ] 331 .contains(&line) 332 } 333 334 fn is_allowed_raw_boundary(struct_name: &str) -> bool { 335 struct_name == "RadrootsOrderEnvelope" 336 || struct_name == "RadrootsValidationReceiptTags" 337 || struct_name == "RadrootsTradeListing" 338 || struct_name.ends_with("Projection") 339 || struct_name.ends_with("Accounting") 340 || struct_name.ends_with("Availability") 341 || struct_name.ends_with("Reservation") 342 || struct_name.ends_with("Issue") 343 || struct_name.ends_with("NormalizedInventoryCount") 344 } 345 346 fn files_under(root: &Path, rel_roots: &[PathBuf]) -> Vec<PathBuf> { 347 let mut files = Vec::new(); 348 for rel_root in rel_roots { 349 collect_files(root.join(rel_root), &mut files); 350 } 351 files.sort(); 352 files 353 } 354 355 fn collect_files(path: PathBuf, files: &mut Vec<PathBuf>) { 356 let Ok(metadata) = fs::metadata(&path) else { 357 return; 358 }; 359 if metadata.is_file() { 360 if matches!( 361 path.extension().and_then(|ext| ext.to_str()), 362 Some("json" | "md" | "nix" | "rs" | "sh" | "sql" | "toml") 363 ) { 364 files.push(path); 365 } 366 return; 367 } 368 let Ok(entries) = fs::read_dir(path) else { 369 return; 370 }; 371 for entry in entries.flatten() { 372 collect_files(entry.path(), files); 373 } 374 } 375 376 fn display_path(root: &Path, file: &Path) -> String { 377 file.strip_prefix(root) 378 .unwrap_or(file) 379 .to_string_lossy() 380 .to_string() 381 } 382 383 #[cfg(test)] 384 mod tests { 385 use super::*; 386 use std::time::{SystemTime, UNIX_EPOCH}; 387 388 fn unique_temp_dir(prefix: &str) -> PathBuf { 389 let ns = SystemTime::now() 390 .duration_since(UNIX_EPOCH) 391 .expect("system time") 392 .as_nanos(); 393 std::env::temp_dir().join(format!("radroots_xtask_hygiene_{prefix}_{ns}")) 394 } 395 396 fn write_file(root: &Path, rel: &str, content: &str) { 397 let path = root.join(rel); 398 fs::create_dir_all(path.parent().expect("parent")).expect("create parent"); 399 fs::write(path, content).expect("write"); 400 } 401 402 #[test] 403 fn forbidden_identifiers_accept_clean_synthetic_tree() { 404 let root = unique_temp_dir("clean"); 405 write_file( 406 &root, 407 "crates/relay_transport/src/fetch.rs", 408 "fn fetch() { let _ = RadrootsEventIngest::new; }\n", 409 ); 410 write_file( 411 &root, 412 "crates/event_store/src/store.rs", 413 "pub struct RadrootsProjectionCursor { pub last_event_seq: i64 }\n", 414 ); 415 write_file( 416 &root, 417 "crates/trade/src/order.rs", 418 "pub struct RadrootsOrderProjection { pub order_id: RadrootsOrderId, }\n", 419 ); 420 validate_forbidden_identifiers(&root).expect("clean tree"); 421 let _ = fs::remove_dir_all(root); 422 } 423 424 #[test] 425 fn forbidden_identifiers_reject_regressions() { 426 let root = unique_temp_dir("dirty"); 427 write_file( 428 &root, 429 "crates/relay_transport/src/fetch.rs", 430 "fn fetch() { let _ = RadrootsEventIngest::verified; }\n", 431 ); 432 write_file( 433 &root, 434 "crates/event_store/src/store.rs", 435 "pub struct Cursor { pub last_event_id: String }\n", 436 ); 437 write_file( 438 &root, 439 "crates/trade/src/order.rs", 440 "pub struct BadOrder {\n pub order_id: String,\n}\n", 441 ); 442 write_file(&root, "contracts/events/social-events.md", "tangle\n"); 443 write_file( 444 &root, 445 "crates/events/src/kinds.rs", 446 "pub const KIND_TRADE_LISTING_ORDER: u64 = 1;\n", 447 ); 448 write_file( 449 &root, 450 "Cargo.toml", 451 "[workspace]\n[workspace.dependencies]\nwasm-bindgen = \"0.2\"\nuniffi = \"0.29\"\n", 452 ); 453 fs::create_dir_all(root.join("crates/sql_wasm_bridge")).expect("create wasm crate dir"); 454 fs::create_dir_all(root.join("scripts")).expect("create scripts dir"); 455 fs::create_dir_all(root.join("contracts/sdk-exports")).expect("create sdk exports dir"); 456 let err = validate_forbidden_identifiers(&root).expect_err("dirty tree"); 457 assert!(err.contains("relay fetch must not bypass event-store verification")); 458 assert!(err.contains("event-store projection cursors must use last_event_seq")); 459 assert!(err.contains("raw commercial protocol identifier String fields are forbidden")); 460 assert!(err.contains("legacy identifier 'tangle' must not reappear")); 461 assert!(err.contains("legacy trade listing kind constants must not reappear")); 462 assert!(err.contains("wasm-bindgen")); 463 assert!(err.contains("uniffi")); 464 assert!(err.contains("crates/sql_wasm_bridge")); 465 assert!(err.contains("scripts")); 466 assert!(err.contains("contracts/sdk-exports")); 467 let _ = fs::remove_dir_all(root); 468 } 469 470 #[test] 471 fn run_dispatches_forbidden_identifiers() { 472 let root = unique_temp_dir("run"); 473 write_file( 474 &root, 475 "crates/relay_transport/src/fetch.rs", 476 "fn fetch() { let _ = RadrootsEventIngest::new; }\n", 477 ); 478 run(&["forbidden-identifiers".to_string()], &root).expect("hygiene run"); 479 let unknown = run(&["unknown".to_string()], &root).expect_err("unknown hygiene command"); 480 assert!(unknown.contains("unknown hygiene subcommand")); 481 let _ = fs::remove_dir_all(root); 482 } 483 }