Skip to main content

claude_native/rules/
drift.rs

1use crate::rules::*;
2use crate::scan::ProjectContext;
3
4// ── Rule 6.1: CLAUDE.md build command matches manifest ──────────────
5
6pub 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        // Check for mismatches between CLAUDE.md commands and actual manifests
21        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        // Use word boundary: "go build" but not "cargo build"
29        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
66// ── Rule 6.2: Referenced files exist ────────────────────────────────
67
68pub 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    // Look for backtick-quoted paths that look like file references
103    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    // Deduplicate
110    missing.sort();
111    missing.dedup();
112    missing.truncate(5); // limit output
113    missing
114}
115
116fn looks_like_file_path(s: &str) -> bool {
117    // Must contain / or . extension, not be a command, and be reasonable length
118    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    // Must look like a relative file path
128    is_path && (s.ends_with('/') || s.contains('.'))
129        && !s.starts_with('$')
130        && !s.contains('(')
131}