-
Notifications
You must be signed in to change notification settings - Fork 0
Add custom lint rules to enforce domain types over raw strings #98
Description
Domain Type Lint Enforcement
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Enforce that domain types (IdentityDID, CanonicalDid, DeviceDID, Ecosystem, PackageName) are used instead of raw strings, with CI-friendly grep checks and new_unchecked() audit.
Architecture: Add a new check-domain-types xtask command that greps for raw-string anti-patterns in .rs files, excluding auths-transparency. Audit all new_unchecked() call sites and migrate production code to parse(). The clippy disallowed-methods rules for new_unchecked() already exist in clippy.toml — this work focuses on the grep layer and call-site cleanup.
Tech Stack: Rust, xtask, just, GitHub Actions CI
Task 1: Add check-domain-types xtask subcommand
This is the grep-based lint. It scans .rs files under crates/ for known anti-patterns (DID params as raw strings, ecosystem/package_name as raw strings), excluding auths-transparency and lines with // allow-raw-did / // allow-raw-ecosystem / // allow-raw-package-name escape hatches.
Files:
- Create:
crates/xtask/src/check_domain_types.rs - Modify:
crates/xtask/src/main.rs
Step 1: Create the check module
Create crates/xtask/src/check_domain_types.rs:
use std::path::Path;
/// Anti-patterns: raw-string parameters that should use domain types.
/// Each entry: (grep pattern, escape-hatch comment, human description).
const RULES: &[(&str, &str, &str)] = &[
// DID parameters passed as raw &str / String
("did: &str", "allow-raw-did", "DID param should use IdentityDID, DeviceDID, or CanonicalDid"),
("did: String", "allow-raw-did", "DID param should use IdentityDID, DeviceDID, or CanonicalDid"),
("_did: &str", "allow-raw-did", "DID param should use IdentityDID, DeviceDID, or CanonicalDid"),
("_did: String", "allow-raw-did", "DID param should use IdentityDID, DeviceDID, or CanonicalDid"),
// Ecosystem as raw string
("ecosystem: String", "allow-raw-ecosystem", "ecosystem param should use Ecosystem enum"),
("ecosystem: &str", "allow-raw-ecosystem", "ecosystem param should use Ecosystem enum"),
// PackageName as raw string
("package_name: String", "allow-raw-package-name", "package_name param should use PackageName type"),
("package_name: &str", "allow-raw-package-name", "package_name param should use PackageName type"),
];
/// Crates exempt from domain-type checks (serialization boundaries).
const EXEMPT_CRATES: &[&str] = &["auths-transparency"];
pub fn run(workspace_root: &Path) -> anyhow::Result<()> {
let crates_dir = workspace_root.join("crates");
let mut violations: Vec<String> = Vec::new();
for entry in std::fs::read_dir(&crates_dir)? {
let entry = entry?;
let crate_name = entry.file_name().to_string_lossy().to_string();
if EXEMPT_CRATES.contains(&crate_name.as_str()) {
continue;
}
let src_dir = entry.path().join("src");
if src_dir.is_dir() {
scan_dir(&src_dir, &crate_name, &mut violations)?;
}
// Also scan tests/ directory
let tests_dir = entry.path().join("tests");
if tests_dir.is_dir() {
scan_dir(&tests_dir, &crate_name, &mut violations)?;
}
}
if violations.is_empty() {
println!("domain-type check OK — no raw-string anti-patterns found");
Ok(())
} else {
for v in &violations {
eprintln!("{v}");
}
anyhow::bail!(
"domain-type check failed: {} violation(s) found. \
Add an escape-hatch comment (e.g. // allow-raw-did) to suppress intentional uses.",
violations.len()
)
}
}
fn scan_dir(dir: &Path, crate_name: &str, violations: &mut Vec<String>) -> anyhow::Result<()> {
for entry in walkdir(dir)? {
let path = entry;
if path.extension().is_some_and(|ext| ext == "rs") {
check_file(&path, crate_name, violations)?;
}
}
Ok(())
}
fn walkdir(dir: &Path) -> anyhow::Result<Vec<std::path::PathBuf>> {
let mut files = Vec::new();
walk_recursive(dir, &mut files)?;
Ok(files)
}
fn walk_recursive(dir: &Path, files: &mut Vec<std::path::PathBuf>) -> anyhow::Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
walk_recursive(&path, files)?;
} else {
files.push(path);
}
}
Ok(())
}
fn check_file(path: &Path, crate_name: &str, violations: &mut Vec<String>) -> anyhow::Result<()> {
let content = std::fs::read_to_string(path)?;
for (line_num, line) in content.lines().enumerate() {
let line_num = line_num + 1; // 1-based
for &(pattern, escape_hatch, description) in RULES {
if line.contains(pattern) && !line.contains(escape_hatch) {
violations.push(format!(
" {crate_name} {path}:{line_num}: \"{pattern}\" — {description}",
path = path.display(),
));
}
}
}
Ok(())
}Step 2: Wire it into xtask main.rs
In crates/xtask/src/main.rs, add mod check_domain_types; to the imports, add the subcommand variant, and add the match arm:
Add to imports (after mod check_clippy_sync;):
mod check_domain_types;Add to Command enum:
/// Check for raw-string anti-patterns where domain types should be used.
CheckDomainTypes,Add to match block:
Command::CheckDomainTypes => check_domain_types::run(workspace_root()),Step 3: Run the check locally to see current violations
cargo run -p xtask -- check-domain-typesExpected: FAIL with a list of violations (the existing raw-string usages). This output tells us which lines need escape-hatch comments added in Task 2.
Task 2: Annotate intentional raw-string usages with escape hatches
After running the check in Task 1, go through each violation and decide:
- If the raw string is intentional (serialization, CLI parsing boundary, error display, trait impl forced by external type) → add the escape-hatch comment
- If the raw string should be migrated to a domain type → leave it for a future PR (out of scope here unless trivial)
The goal is to make check-domain-types pass with zero false positives.
Guidelines for which violations get escape hatches vs. fixes:
Escape hatch (add // allow-raw-did etc.):
- Serde
Deserializefield definitions (must accept raw string from JSON) - CLI clap argument fields (string comes from user input, parsed later)
- Error enum variants that carry the raw string for display
- Trait implementations where the trait signature uses
&str - Test code that intentionally constructs invalid/malformed values
Fix (change type to domain type):
- Struct fields in SDK workflow params/responses where the value is already validated upstream
- Function parameters in SDK/core that always receive validated data
Step 1: Run the check and capture output
cargo run -p xtask -- check-domain-types 2>&1 | tee /tmp/domain-type-violations.txtStep 2: For each violation, add the appropriate escape-hatch comment
Add // allow-raw-did, // allow-raw-ecosystem, or // allow-raw-package-name at the end of lines that are intentionally using raw strings. Example:
// Before:
pub ecosystem: String,
// After (clap arg field — parsed to Ecosystem in handler):
pub ecosystem: String, // allow-raw-ecosystemStep 3: Re-run to confirm zero violations
cargo run -p xtask -- check-domain-typesExpected: domain-type check OK — no raw-string anti-patterns found
Task 3: Add just lint recipe and CI integration
Files:
- Modify:
justfile - Modify:
.github/workflows/ci.yml
Step 1: Add lint recipe to justfile
Add after the install recipe:
# Run domain-type and clippy-sync lint checks.
lint:
cargo run -p xtask -- check-domain-types
cargo run -p xtask -- check-clippy-syncStep 2: Add to CI workflow
In .github/workflows/ci.yml, add check-domain-types to the existing clippy job, after the check-clippy-sync step:
- run: cargo run -p xtask -- check-domain-typesStep 3: Verify locally
just lintExpected: Both checks pass.
Task 4: Audit new_unchecked() call sites in production code
The clippy disallowed-methods rules already exist. This task audits current call sites to ensure they all have proper #[allow(clippy::disallowed_methods)] + INVARIANT: comments, or can be migrated to parse().
Target files (production code only — test code is exempt):
crates/auths-radicle/src/storage.rs— lines 188, 209, 268crates/auths-radicle/src/attestation.rs— lines 228-230crates/auths-radicle/src/verify.rs— line 273
Step 1: For each production call site, verify it has the required annotation pattern
Each new_unchecked() in production code must have:
#[allow(clippy::disallowed_methods)] // INVARIANT: <why this is safe>If a call site lacks this pattern, either:
- Add the annotation with a clear INVARIANT comment, OR
- Migrate to
parse()with proper error handling if the value could genuinely be invalid
Step 2: Verify clippy passes
cargo clippy --all-targets --all-features -- -D warningsExpected: No new warnings.
Task 5: Final verification
Step 1: Run full lint suite
just lintExpected: All checks pass.
Step 2: Run clippy
cargo clippy --all-targets --all-features -- -D warningsExpected: Clean.
Step 3: Run tests
cargo nextest run --workspace --all-featuresExpected: All pass.
Acceptance Criteria Mapping
| Criterion | Addressed By |
|---|---|
just lint target exists and runs in CI |
Task 3 |
Grep checks catch did: &str patterns |
Task 1 (xtask) |
new_unchecked() usage audited and minimized |
Task 4 |
No false positives on auths-transparency |
Task 1 (EXEMPT_CRATES) |