Skip to content

Add custom lint rules to enforce domain types over raw strings #98

@bordumb

Description

@bordumb

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-types

Expected: 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 Deserialize field 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.txt

Step 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-ecosystem

Step 3: Re-run to confirm zero violations

cargo run -p xtask -- check-domain-types

Expected: 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-sync

Step 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-types

Step 3: Verify locally

just lint

Expected: 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):

  1. crates/auths-radicle/src/storage.rs — lines 188, 209, 268
  2. crates/auths-radicle/src/attestation.rs — lines 228-230
  3. crates/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 warnings

Expected: No new warnings.


Task 5: Final verification

Step 1: Run full lint suite

just lint

Expected: All checks pass.

Step 2: Run clippy

cargo clippy --all-targets --all-features -- -D warnings

Expected: Clean.

Step 3: Run tests

cargo nextest run --workspace --all-features

Expected: 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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions