claude_native/rules/
drift.rs1use crate::rules::*;
2use crate::scan::ProjectContext;
3
4pub struct BuildCommandMatchesManifest;
7
8impl Rule for BuildCommandMatchesManifest {
9 fn id(&self) -> &str { "6.1" }
10 fn name(&self) -> &str { "CLAUDE.md build cmd matches manifest" }
11 fn dimension(&self) -> Dimension { Dimension::Foundation }
12 fn severity(&self) -> Severity { Severity::Medium }
13
14 fn check(&self, ctx: &ProjectContext) -> RuleResult {
15 let content = match &ctx.claude_md_content {
16 Some(c) => c.to_lowercase(),
17 None => return self.skip(),
18 };
19
20 let has_cargo = ctx.has_file("Cargo.toml");
22 let has_npm = ctx.has_file("package.json");
23 let has_go = ctx.has_file("go.mod");
24 let has_python = ctx.has_file("requirements.txt") || ctx.has_file("pyproject.toml");
25
26 let mentions_cargo = content.contains("cargo ");
27 let mentions_npm = content.contains("npm ") || content.contains("npx ");
28 let mentions_go = content.contains(" go build") || content.contains(" go test")
30 || content.contains("`go ") || content.starts_with("go ");
31 let mentions_python = content.contains("python ") || content.contains("pytest") || content.contains("pip ");
32
33 let mismatches = check_mismatches(
34 has_cargo, has_npm, has_go, has_python,
35 mentions_cargo, mentions_npm, mentions_go, mentions_python,
36 );
37
38 if mismatches.is_empty() {
39 self.pass()
40 } else {
41 self.warn(
42 &format!("CLAUDE.md may be stale: {}", mismatches.join("; ")),
43 Suggestion {
44 priority: SuggestionPriority::QuickWin,
45 title: "Update CLAUDE.md commands".into(),
46 description: format!("CLAUDE.md references commands that don't match your project: {}. Update to match actual tooling.", mismatches.join(", ")),
47 effort: Effort::Minutes,
48 },
49 )
50 }
51 }
52}
53
54fn check_mismatches(
55 has_cargo: bool, has_npm: bool, has_go: bool, has_python: bool,
56 mentions_cargo: bool, mentions_npm: bool, mentions_go: bool, mentions_python: bool,
57) -> Vec<String> {
58 let mut m = Vec::new();
59 if mentions_cargo && !has_cargo { m.push("mentions cargo but no Cargo.toml".into()); }
60 if mentions_npm && !has_npm { m.push("mentions npm but no package.json".into()); }
61 if mentions_go && !has_go { m.push("mentions go but no go.mod".into()); }
62 if mentions_python && !has_python { m.push("mentions python but no requirements.txt/pyproject.toml".into()); }
63 m
64}
65
66pub struct ReferencedFilesExist;
69
70impl Rule for ReferencedFilesExist {
71 fn id(&self) -> &str { "6.2" }
72 fn name(&self) -> &str { "Files referenced in CLAUDE.md exist" }
73 fn dimension(&self) -> Dimension { Dimension::Foundation }
74 fn severity(&self) -> Severity { Severity::Medium }
75
76 fn check(&self, ctx: &ProjectContext) -> RuleResult {
77 let content = match &ctx.claude_md_content {
78 Some(c) => c,
79 None => return self.skip(),
80 };
81
82 let missing = find_missing_references(content, ctx);
83
84 if missing.is_empty() {
85 self.pass()
86 } else {
87 self.warn(
88 &format!("CLAUDE.md references files that don't exist: {}", missing.join(", ")),
89 Suggestion {
90 priority: SuggestionPriority::QuickWin,
91 title: "Fix stale file references in CLAUDE.md".into(),
92 description: format!("These paths in CLAUDE.md don't exist: {}. Either create them or update CLAUDE.md.", missing.join(", ")),
93 effort: Effort::Minutes,
94 },
95 )
96 }
97 }
98}
99
100fn find_missing_references(content: &str, ctx: &ProjectContext) -> Vec<String> {
101 let mut missing = Vec::new();
102 for segment in content.split('`') {
104 let trimmed = segment.trim();
105 if looks_like_file_path(trimmed) && !ctx.has_file(trimmed) {
106 missing.push(trimmed.to_string());
107 }
108 }
109 missing.sort();
111 missing.dedup();
112 missing.truncate(5); missing
114}
115
116fn looks_like_file_path(s: &str) -> bool {
117 let is_path = (s.contains('/') || s.contains('.'))
119 && s.len() > 3
120 && s.len() < 100
121 && !s.contains(' ')
122 && !s.starts_with("http")
123 && !s.starts_with("--")
124 && !s.starts_with("npm ")
125 && !s.starts_with("cargo ")
126 && !s.starts_with("go ");
127 is_path && (s.ends_with('/') || s.contains('.'))
129 && !s.starts_with('$')
130 && !s.contains('(')
131}