lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

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 }