commit 4476586cc10a6f1b1ee58f271991a6be765c791d
parent c4d7ebfd91d4725fa440578df7d59de400c0065b
Author: triesap <tyson@radroots.org>
Date: Mon, 27 Jul 2026 20:49:23 +0000
workspace: enforce snake case crate identities
- reject kebab-case Cargo package names and crate directories
- reject kebab-case Radroots dependency keys in every manifest section
- correct smoke, preview-gate, and normative package identities
- preserve synchronized specification integrity hashes
Diffstat:
3 files changed, 165 insertions(+), 12 deletions(-)
diff --git a/tools/sdk_xtask_import/src/architecture.rs b/tools/sdk_xtask_import/src/architecture.rs
@@ -810,6 +810,10 @@ fn validate_workspace_members(workspace_root: &Path) -> Result<(), String> {
.map_err(|error| format!("read {}: {error}", manifest_path.display()))?;
let manifest = toml::from_str::<WorkspaceMembershipManifest>(&manifest_raw)
.map_err(|error| format!("parse {}: {error}", manifest_path.display()))?;
+ let manifest_value = manifest_raw
+ .parse::<toml::Value>()
+ .map_err(|error| format!("parse {}: {error}", manifest_path.display()))?;
+ validate_manifest_dependency_keys(&manifest_value, "Cargo.toml")?;
let declared = manifest
.workspace
.members
@@ -829,7 +833,42 @@ fn validate_workspace_members(workspace_root: &Path) -> Result<(), String> {
.is_dir()
&& entry.path().join("Cargo.toml").is_file()
{
- discovered.insert(format!("{root}/{}", entry.file_name().to_string_lossy()));
+ let directory_name = entry.file_name().to_string_lossy().into_owned();
+ require_lower_snake_case(
+ &directory_name,
+ &format!("{root}/{directory_name} crate directory"),
+ )?;
+ let package_manifest_path = entry.path().join("Cargo.toml");
+ let package_manifest_raw =
+ fs::read_to_string(&package_manifest_path).map_err(|error| {
+ format!("read {}: {error}", package_manifest_path.display())
+ })?;
+ let package_manifest =
+ package_manifest_raw
+ .parse::<toml::Value>()
+ .map_err(|error| {
+ format!("parse {}: {error}", package_manifest_path.display())
+ })?;
+ let package_name = package_manifest
+ .get("package")
+ .and_then(toml::Value::as_table)
+ .and_then(|package| package.get("name"))
+ .and_then(toml::Value::as_str)
+ .ok_or_else(|| {
+ format!(
+ "{} is missing package.name",
+ package_manifest_path.display()
+ )
+ })?;
+ require_lower_snake_case(
+ package_name,
+ &format!("{} package.name", package_manifest_path.display()),
+ )?;
+ validate_manifest_dependency_keys(
+ &package_manifest,
+ &package_manifest_path.display().to_string(),
+ )?;
+ discovered.insert(format!("{root}/{directory_name}"));
}
}
}
@@ -851,6 +890,79 @@ fn validate_workspace_members(workspace_root: &Path) -> Result<(), String> {
Ok(())
}
+fn validate_manifest_dependency_keys(manifest: &toml::Value, label: &str) -> Result<(), String> {
+ let Some(manifest) = manifest.as_table() else {
+ return Err(format!("{label} must be a TOML table"));
+ };
+ for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
+ if let Some(dependencies) = manifest.get(section).and_then(toml::Value::as_table) {
+ validate_dependency_keys(dependencies, label, section)?;
+ }
+ }
+ if let Some(workspace_dependencies) = manifest
+ .get("workspace")
+ .and_then(toml::Value::as_table)
+ .and_then(|workspace| workspace.get("dependencies"))
+ .and_then(toml::Value::as_table)
+ {
+ validate_dependency_keys(workspace_dependencies, label, "workspace.dependencies")?;
+ }
+ if let Some(targets) = manifest.get("target").and_then(toml::Value::as_table) {
+ for (target, target_value) in targets {
+ let Some(target_table) = target_value.as_table() else {
+ continue;
+ };
+ for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
+ if let Some(dependencies) =
+ target_table.get(section).and_then(toml::Value::as_table)
+ {
+ validate_dependency_keys(
+ dependencies,
+ label,
+ &format!("target.{target}.{section}"),
+ )?;
+ }
+ }
+ }
+ }
+ Ok(())
+}
+
+fn validate_dependency_keys(
+ dependencies: &toml::value::Table,
+ label: &str,
+ section: &str,
+) -> Result<(), String> {
+ for key in dependencies
+ .keys()
+ .filter(|key| key.starts_with("radroots"))
+ {
+ require_lower_snake_case(key, &format!("{label} {section} dependency key"))?;
+ }
+ Ok(())
+}
+
+fn require_lower_snake_case(value: &str, label: &str) -> Result<(), String> {
+ if is_lower_snake_case(value) {
+ Ok(())
+ } else {
+ Err(format!("{label} `{value}` must use lowercase snake case"))
+ }
+}
+
+fn is_lower_snake_case(value: &str) -> bool {
+ !value.is_empty()
+ && value.split('_').all(|segment| {
+ segment
+ .as_bytes()
+ .first()
+ .is_some_and(u8::is_ascii_lowercase)
+ && segment
+ .bytes()
+ .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
+ })
+}
+
fn validate_ledger(
workspace_root: &Path,
expected_architecture_id: &str,
@@ -1125,6 +1237,50 @@ adr_required = false
}
#[test]
+ fn workspace_identity_rejects_kebab_case_crate_names_and_dependency_keys() {
+ let root = test_root("workspace_snake_case_identity");
+ fs::create_dir_all(root.join("crates/radroots_probe")).expect("create package root");
+ fs::create_dir_all(root.join("tools/xtask")).expect("create xtask root");
+ fs::write(
+ root.join("Cargo.toml"),
+ "[workspace]\nmembers = [\"crates/radroots_probe\", \"tools/xtask\"]\nresolver = \"3\"\n",
+ )
+ .expect("write workspace manifest");
+ fs::write(
+ root.join("crates/radroots_probe/Cargo.toml"),
+ "[package]\nname = \"radroots-probe\"\n",
+ )
+ .expect("write kebab package manifest");
+ fs::write(
+ root.join("tools/xtask/Cargo.toml"),
+ "[package]\nname = \"xtask\"\n",
+ )
+ .expect("write xtask manifest");
+
+ let package_error = validate_workspace_members(&root)
+ .expect_err("kebab-case Cargo package identity must fail");
+ assert!(package_error.contains("package.name"));
+ assert!(package_error.contains("lowercase snake case"));
+
+ fs::write(
+ root.join("crates/radroots_probe/Cargo.toml"),
+ "[package]\nname = \"radroots_probe\"\n",
+ )
+ .expect("write snake package manifest");
+ fs::write(
+ root.join("Cargo.toml"),
+ "[workspace]\nmembers = [\"crates/radroots_probe\", \"tools/xtask\"]\nresolver = \"3\"\n\n[workspace.dependencies]\nradroots-probe = \"0.1.0\"\n",
+ )
+ .expect("write kebab dependency key");
+
+ let dependency_error = validate_workspace_members(&root)
+ .expect_err("kebab-case Radroots dependency key must fail");
+ assert!(dependency_error.contains("workspace.dependencies dependency key"));
+ assert!(dependency_error.contains("lowercase snake case"));
+ let _ = fs::remove_dir_all(root);
+ }
+
+ #[test]
fn workspace_toolchain_requires_exact_resolver_and_rust_version() {
let root = test_root("workspace_toolchain");
fs::write(root.join("Cargo.toml"), complete_workspace_manifest(""))
diff --git a/tools/sdk_xtask_import/src/check.rs b/tools/sdk_xtask_import/src/check.rs
@@ -29,7 +29,7 @@ const PACKAGE_LICENSE: &str = "MIT OR Apache-2.0";
const PACKAGE_HOMEPAGE: &str = "https://radroots.org";
const PACKAGE_REPOSITORY_URL: &str = "git+https://github.com/radrootslabs/sdk.git";
const PUBLISH_ACCESS: &str = "public";
-const ALLOWED_RETICULUM_PREVIEW_PACKAGE: &str = "radroots-transport-reticulum";
+const ALLOWED_RETICULUM_PREVIEW_PACKAGE: &str = "radroots_transport_reticulum";
const RADROOTS_FACADE_MODULES: [&str; 11] = [
"client",
"event",
@@ -730,8 +730,7 @@ fn check_reticulum_feature_entry(
.map_or((feature_dependency, None), |(name, feature)| {
(name, Some(feature))
});
- let normalized_dependency = normalize_package_name(dependency_name);
- if normalized_dependency == ALLOWED_RETICULUM_PREVIEW_PACKAGE && dependency_feature.is_some() {
+ if dependency_name == ALLOWED_RETICULUM_PREVIEW_PACKAGE && dependency_feature.is_some() {
return Err(format!(
"{manifest_label} feature `{feature_name}` must not enable Reticulum preview crate features through `{entry}`"
));
@@ -796,10 +795,10 @@ fn reject_forbidden_reticulum_runtime_name(
}
fn is_forbidden_reticulum_runtime_name(name: &str) -> bool {
- let normalized = normalize_package_name(name);
- if normalized == ALLOWED_RETICULUM_PREVIEW_PACKAGE {
+ if name == ALLOWED_RETICULUM_PREVIEW_PACKAGE {
return false;
}
+ let normalized = normalize_package_name(name);
matches!(
normalized.as_str(),
"rns" | "rnsd" | "reticulum" | "reticulum-rs" | "python" | "pyo3"
@@ -2442,17 +2441,14 @@ transport-reticulum-preview = []
"python-runtime",
"pyo3",
"pyo3-ffi",
+ "radroots-transport-reticulum",
] {
assert!(
is_forbidden_reticulum_runtime_name(name),
"{name} must be forbidden"
);
}
- for name in [
- "radroots_transport_reticulum",
- "radroots-transport-reticulum",
- "dto_bindgen_backend_python",
- ] {
+ for name in ["radroots_transport_reticulum", "dto_bindgen_backend_python"] {
assert!(
!is_forbidden_reticulum_runtime_name(name),
"{name} must be allowed"
diff --git a/tools/sdk_xtask_import/src/smoke.rs b/tools/sdk_xtask_import/src/smoke.rs
@@ -60,7 +60,7 @@ fn render_consumer_manifest(sdk_path: &Path, nostr_version: &str) -> Result<Stri
.map_err(|error| format!("failed to render SDK path: {error}"))?;
Ok(format!(
r#"[package]
-name = "radroots-sdk-knowledge-smoke"
+name = "radroots_sdk_knowledge_smoke"
version = "0.1.0"
edition = "2024"
publish = false
@@ -220,6 +220,7 @@ mod tests {
let manifest =
render_consumer_manifest(Path::new("/tmp/radroots_sdk"), "=0.44.2").expect("manifest");
+ assert!(manifest.contains("name = \"radroots_sdk_knowledge_smoke\""));
assert!(manifest.contains("nostr = \"=0.44.2\""));
assert!(!manifest.contains("nostr = \"0.44.2\""));
}