claude-native 0.2.0

Scan any project and score how Claude Native it is — optimized for AI-assisted development
Documentation
use crate::rules::*;
use crate::scan::ProjectContext;

// ═══════════════════════════════════════════════════════════════════
// R10: Mixed generated + handwritten code
// ═══════════════════════════════════════════════════════════════════

pub struct MixedGeneratedCode;

impl Rule for MixedGeneratedCode {
    fn id(&self) -> &str { "7.10" }
    fn name(&self) -> &str { "No mixed generated + handwritten files" }
    fn dimension(&self) -> Dimension { Dimension::ContextEfficiency }
    fn severity(&self) -> Severity { Severity::Low }

    fn check(&self, ctx: &ProjectContext) -> RuleResult {
        let gen_markers = ["@generated", "AUTO-GENERATED", "DO NOT EDIT",
            "Code generated by", "auto-generated", "THIS FILE IS GENERATED"];

        let mut mixed_files = Vec::new();
        let source = ctx.source_files();

        for f in source.iter().take(30) {
            let content = match std::fs::read_to_string(&f.path) {
                Ok(c) => c,
                Err(_) => continue,
            };
            if !gen_markers.iter().any(|m| content.contains(m)) { continue; }

            let total = content.lines().count();
            let marker_line = content.lines().enumerate()
                .filter(|(_, l)| gen_markers.iter().any(|m| l.contains(m)))
                .map(|(i, _)| i).last().unwrap_or(0);

            let after = total.saturating_sub(marker_line);
            if after as f64 / total.max(1) as f64 > 0.3 {
                mixed_files.push(f.relative_path.to_string_lossy().to_string());
            }
        }

        if mixed_files.is_empty() {
            self.pass()
        } else {
            self.warn(
                &format!("{} file(s) mix generated and handwritten code", mixed_files.len()),
                Suggestion {
                    priority: SuggestionPriority::NiceToHave,
                    title: "Separate generated from handwritten code".into(),
                    description: format!("Files: {}. Split so Claude doesn't edit generated sections.", mixed_files.join(", ")),
                    effort: Effort::Hour,
                },
            )
        }
    }
}

// ═══════════════════════════════════════════════════════════════════
// R13: Claude memory directory
// ═══════════════════════════════════════════════════════════════════

pub struct MemoryDirectoryExists;

impl Rule for MemoryDirectoryExists {
    fn id(&self) -> &str { "7.11" }
    fn name(&self) -> &str { "Claude memory is being used" }
    fn dimension(&self) -> Dimension { Dimension::Foundation }
    fn severity(&self) -> Severity { Severity::Low }

    fn check(&self, ctx: &ProjectContext) -> RuleResult {
        let home = std::env::var("HOME").unwrap_or_default();
        let claude_dir = std::path::PathBuf::from(&home).join(".claude");
        let has_memory = claude_dir.join("projects").is_dir();

        let mentions_memory = ctx.claude_md_content.as_ref().map(|c| {
            let l = c.to_lowercase();
            l.contains("memory") || l.contains("remember") || l.contains("context")
        }).unwrap_or(false);

        if has_memory || mentions_memory {
            self.pass()
        } else {
            self.warn(
                "Claude memory system not detected",
                Suggestion {
                    priority: SuggestionPriority::NiceToHave,
                    title: "Use Claude's memory system".into(),
                    description: "Claude Code persists project context across sessions via memory. \
                        This saves ~2000 tokens/session by avoiding re-exploration. \
                        Memories are created automatically during Claude Code sessions.".into(),
                    effort: Effort::Minutes,
                },
            )
        }
    }
}

// ═══════════════════════════════════════════════════════════════════
// R6: Circular dependency detection
// ═══════════════════════════════════════════════════════════════════

pub struct CircularDependencyCheck;

impl Rule for CircularDependencyCheck {
    fn id(&self) -> &str { "7.8" }
    fn name(&self) -> &str { "No circular dependencies detected" }
    fn dimension(&self) -> Dimension { Dimension::Navigation }
    fn severity(&self) -> Severity { Severity::Low }

    fn check(&self, ctx: &ProjectContext) -> RuleResult {
        let cycles = find_simple_cycles(ctx);
        if cycles.is_empty() { self.pass() }
        else {
            self.warn(
                &format!("{} potential circular dep(s)", cycles.len()),
                Suggestion {
                    priority: SuggestionPriority::NiceToHave,
                    title: "Review circular dependencies".into(),
                    description: format!("Found:\n{}", cycles.iter().take(3).cloned().collect::<Vec<_>>().join("\n")),
                    effort: Effort::HalfDay,
                },
            )
        }
    }
}

fn find_simple_cycles(ctx: &ProjectContext) -> Vec<String> {
    let mut cycles = Vec::new();
    let source = ctx.source_files();
    let files: Vec<_> = source.iter().filter(|f| f.line_count > 10).take(30).collect();
    for (i, a) in files.iter().enumerate() {
        let a_name = a.relative_path.file_stem().and_then(|n| n.to_str()).unwrap_or("");
        let a_content = std::fs::read_to_string(&a.path).unwrap_or_default();
        for b in files.iter().skip(i + 1) {
            let b_name = b.relative_path.file_stem().and_then(|n| n.to_str()).unwrap_or("");
            let b_content = std::fs::read_to_string(&b.path).unwrap_or_default();
            if has_import(&a_content, b_name) && has_import(&b_content, a_name) {
                cycles.push(format!("  {} <-> {}", a.relative_path.display(), b.relative_path.display()));
            }
        }
    }
    cycles
}

fn has_import(content: &str, module: &str) -> bool {
    content.lines().any(|l| {
        let t = l.trim();
        (t.starts_with("use ") || t.starts_with("import ") || t.starts_with("from ")) && t.contains(module)
    })
}

// ═══════════════════════════════════════════════════════════════════
// R7: Scattered code cross-references
// ═══════════════════════════════════════════════════════════════════

pub struct ScatteredCodeCrossRefs;

impl Rule for ScatteredCodeCrossRefs {
    fn id(&self) -> &str { "7.9" }
    fn name(&self) -> &str { "Scattered code has cross-references" }
    fn dimension(&self) -> Dimension { Dimension::Navigation }
    fn severity(&self) -> Severity { Severity::Low }

    fn check(&self, ctx: &ProjectContext) -> RuleResult {
        let scattered = find_scattered_concepts(ctx);
        if scattered.is_empty() { return self.pass(); }
        let has_cross_refs = ctx.subdirectory_claude_mds.iter().any(|p| {
            let c = std::fs::read_to_string(p).unwrap_or_default().to_lowercase();
            c.contains("also in") || c.contains("related:") || c.contains("see also")
        });
        if has_cross_refs { self.pass() }
        else {
            self.warn(
                &format!("Scattered: {}", scattered.join(", ")),
                Suggestion {
                    priority: SuggestionPriority::NiceToHave,
                    title: "Add cross-references for scattered code".into(),
                    description: "Add 'also in:' references in folder CLAUDE.md files.".into(),
                    effort: Effort::Minutes,
                },
            )
        }
    }
}

fn find_scattered_concepts(ctx: &ProjectContext) -> Vec<String> {
    use std::collections::{HashMap, HashSet};
    let skip = ["mod", "index", "lib", "main", "test", "CLAUDE"];
    let mut concept_dirs: HashMap<String, HashSet<String>> = HashMap::new();
    for f in ctx.source_files() {
        let stem = f.path.file_stem().and_then(|n| n.to_str()).unwrap_or("");
        let dir = f.path.parent().and_then(|p| p.file_name()).and_then(|n| n.to_str()).unwrap_or("");
        let concept = stem.split('_').next().unwrap_or(stem);
        if concept.len() < 3 || skip.contains(&concept) { continue; }
        concept_dirs.entry(concept.to_string()).or_default().insert(dir.to_string());
    }
    concept_dirs.into_iter()
        .filter(|(_, dirs)| dirs.len() >= 3)
        .map(|(c, _)| c)
        .take(5)
        .collect()
}