use crate::rules::*;
use crate::scan::ProjectContext;
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,
},
)
}
}
}
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,
},
)
}
}
}
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)
})
}
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()
}