Skip to main content

clawft_kernel/assessment/analyzers/
dependency.rs

1//! Dependency analyzer — parses Cargo.toml and package.json for dependency info.
2
3use std::path::{Path, PathBuf};
4
5use crate::assessment::analyzer::Analyzer;
6use crate::assessment::{Finding, analyzer::AnalysisContext};
7
8/// Analyzer that inspects dependency manifests for version info and counts.
9pub struct DependencyAnalyzer;
10
11impl Analyzer for DependencyAnalyzer {
12    fn id(&self) -> &str {
13        "dependency"
14    }
15
16    fn name(&self) -> &str {
17        "Dependency Analyzer"
18    }
19
20    fn categories(&self) -> &[&str] {
21        &["dependency"]
22    }
23
24    fn analyze(
25        &self,
26        project: &Path,
27        files: &[PathBuf],
28        _context: &AnalysisContext,
29    ) -> Vec<Finding> {
30        let mut findings = Vec::new();
31
32        for path in files {
33            let name = match path.file_name().and_then(|n| n.to_str()) {
34                Some(n) => n,
35                None => continue,
36            };
37            let rel = path.strip_prefix(project).unwrap_or(path);
38            let rel_str = rel.display().to_string();
39
40            match name {
41                "Cargo.toml" => {
42                    findings.extend(analyze_cargo_toml(path, &rel_str));
43                }
44                "package.json" => {
45                    findings.extend(analyze_package_json(path, &rel_str));
46                }
47                _ => {}
48            }
49        }
50
51        findings
52    }
53}
54
55fn analyze_cargo_toml(path: &Path, rel_str: &str) -> Vec<Finding> {
56    let mut findings = Vec::new();
57    let content = match std::fs::read_to_string(path) {
58        Ok(c) => c,
59        Err(_) => return findings,
60    };
61
62    let mut in_deps = false;
63    let mut dep_count: usize = 0;
64    let mut missing_version = Vec::new();
65
66    for line in content.lines() {
67        let trimmed = line.trim();
68
69        if trimmed.starts_with('[') {
70            in_deps = trimmed == "[dependencies]"
71                || trimmed == "[dev-dependencies]"
72                || trimmed == "[build-dependencies]"
73                || trimmed.starts_with("[dependencies.")
74                || trimmed.starts_with("[dev-dependencies.")
75                || trimmed.starts_with("[build-dependencies.");
76            continue;
77        }
78
79        if in_deps
80            && !trimmed.is_empty()
81            && !trimmed.starts_with('#')
82            && let Some(dep_name) = trimmed.split('=').next().map(|s| s.trim())
83            && !dep_name.is_empty()
84        {
85            dep_count += 1;
86            // Check for missing version: `dep = "*"` or dep with no version key
87            let value = trimmed
88                .split_once('=')
89                .map(|x| x.1)
90                .map(|s| s.trim())
91                .unwrap_or("");
92            if value == "\"*\"" || value.is_empty() {
93                missing_version.push(dep_name.to_string());
94            }
95        }
96    }
97
98    findings.push(Finding {
99        severity: "info".into(),
100        category: "dependency".into(),
101        file: rel_str.to_string(),
102        line: None,
103        message: format!("Cargo.toml has {dep_count} dependencies"),
104    });
105
106    for dep in missing_version {
107        findings.push(Finding {
108            severity: "warning".into(),
109            category: "dependency".into(),
110            file: rel_str.to_string(),
111            line: None,
112            message: format!("Dependency '{dep}' has no pinned version"),
113        });
114    }
115
116    findings
117}
118
119fn analyze_package_json(path: &Path, rel_str: &str) -> Vec<Finding> {
120    let mut findings = Vec::new();
121    let content = match std::fs::read_to_string(path) {
122        Ok(c) => c,
123        Err(_) => return findings,
124    };
125
126    // Simple JSON parsing without pulling in a full parser — look for
127    // "dependencies" and "devDependencies" object keys and count entries.
128    let mut dep_count: usize = 0;
129    let mut dev_dep_count: usize = 0;
130
131    // Parse with serde_json if available, otherwise count heuristically
132    if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
133        if let Some(deps) = val.get("dependencies").and_then(|v| v.as_object()) {
134            dep_count = deps.len();
135            for (name, ver) in deps {
136                if ver.as_str().map(|s| s == "*").unwrap_or(true) {
137                    findings.push(Finding {
138                        severity: "warning".into(),
139                        category: "dependency".into(),
140                        file: rel_str.to_string(),
141                        line: None,
142                        message: format!("Dependency '{name}' has no pinned version"),
143                    });
144                }
145            }
146        }
147        if let Some(deps) = val.get("devDependencies").and_then(|v| v.as_object()) {
148            dev_dep_count = deps.len();
149        }
150    }
151
152    findings.push(Finding {
153        severity: "info".into(),
154        category: "dependency".into(),
155        file: rel_str.to_string(),
156        line: None,
157        message: format!(
158            "package.json has {dep_count} dependencies, {dev_dep_count} devDependencies"
159        ),
160    });
161
162    findings
163}