sdk

Radroots SDK and bindings
git clone https://radroots.dev/git/sdk.git
Log | Files | Refs | README

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 }