spec_drift/analyzers/
ci.rs1use super::DriftAnalyzer;
2use crate::context::ProjectContext;
3use crate::domain::{Divergence, Location, RuleId, Severity};
4use regex::Regex;
5use std::path::Path;
6use std::process::Command;
7use std::sync::OnceLock;
8
9#[derive(Default)]
16pub struct CiAnalyzer {
17 metadata_override: Option<CargoMetadata>,
18}
19
20impl CiAnalyzer {
21 pub fn with_metadata(md: CargoMetadata) -> Self {
23 Self {
24 metadata_override: Some(md),
25 }
26 }
27}
28
29#[derive(Debug, Clone, Default)]
30pub struct CargoMetadata {
31 pub packages: Vec<String>,
32 pub bins: Vec<String>,
33}
34
35impl CargoMetadata {
36 fn knows_package(&self, name: &str) -> bool {
37 self.packages.iter().any(|p| p == name)
38 }
39 fn knows_bin(&self, name: &str) -> bool {
40 self.bins.iter().any(|b| b == name)
41 }
42
43 fn load(manifest_dir: &Path) -> Self {
44 let out = Command::new("cargo")
45 .current_dir(manifest_dir)
46 .args(["metadata", "--format-version=1", "--no-deps"])
47 .output();
48 let Ok(out) = out else {
49 return Self::default();
50 };
51 if !out.status.success() {
52 return Self::default();
53 }
54 let Ok(v) = serde_json::from_slice::<serde_json::Value>(&out.stdout) else {
55 return Self::default();
56 };
57
58 let mut packages = Vec::new();
59 let mut bins = Vec::new();
60 if let Some(arr) = v.get("packages").and_then(|p| p.as_array()) {
61 for pkg in arr {
62 if let Some(name) = pkg.get("name").and_then(|n| n.as_str()) {
63 packages.push(name.to_string());
64 }
65 if let Some(targets) = pkg.get("targets").and_then(|t| t.as_array()) {
66 for t in targets {
67 let kinds = t
68 .get("kind")
69 .and_then(|k| k.as_array())
70 .cloned()
71 .unwrap_or_default();
72 let is_bin = kinds
73 .iter()
74 .any(|k| k.as_str().is_some_and(|s| s == "bin"));
75 if is_bin
76 && let Some(name) = t.get("name").and_then(|n| n.as_str())
77 {
78 bins.push(name.to_string());
79 }
80 }
81 }
82 }
83 }
84 Self { packages, bins }
85 }
86}
87
88impl DriftAnalyzer for CiAnalyzer {
89 fn id(&self) -> &'static str {
90 "ci"
91 }
92
93 fn analyze(&self, ctx: &ProjectContext) -> Vec<Divergence> {
94 let metadata = self
95 .metadata_override
96 .clone()
97 .unwrap_or_else(|| CargoMetadata::load(&ctx.root));
98
99 let mut out = Vec::new();
100
101 for mk in &ctx.makefile_files {
103 let Ok(src) = std::fs::read_to_string(mk) else {
104 continue;
105 };
106 for (idx, line) in src.lines().enumerate() {
107 let line_no = (idx + 1) as u32;
108 let trimmed = line.trim_start_matches(['\t', ' ']);
109 let trimmed = trimmed.strip_prefix('@').unwrap_or(trimmed);
111 inspect_cargo_line(trimmed, mk, line_no, &metadata, &mut out);
112 }
113 }
114
115 for yaml in &ctx.yaml_files {
117 let Ok(src) = std::fs::read_to_string(yaml) else {
118 continue;
119 };
120 if !is_workflow_path(yaml) {
121 continue;
122 }
123 for (idx, line) in src.lines().enumerate() {
124 let line_no = (idx + 1) as u32;
125 inspect_cargo_line(line, yaml, line_no, &metadata, &mut out);
126 }
127 }
128
129 out
130 }
131}
132
133fn is_workflow_path(path: &Path) -> bool {
134 path.components()
137 .any(|c| c.as_os_str() == ".github")
138 && path.components().any(|c| c.as_os_str() == "workflows")
139}
140
141fn cargo_command_re() -> &'static Regex {
142 static RE: OnceLock<Regex> = OnceLock::new();
143 RE.get_or_init(|| Regex::new(r"(?:^|\s)cargo\s+([a-zA-Z][a-zA-Z0-9\-]*)\b").unwrap())
144}
145
146fn package_arg_re() -> &'static Regex {
147 static RE: OnceLock<Regex> = OnceLock::new();
148 RE.get_or_init(|| {
149 Regex::new(r"(?:--package|-p)[ =]([A-Za-z_][A-Za-z0-9_\-]*)").unwrap()
150 })
151}
152
153fn bin_arg_re() -> &'static Regex {
154 static RE: OnceLock<Regex> = OnceLock::new();
155 RE.get_or_init(|| Regex::new(r"--bin[ =]([A-Za-z_][A-Za-z0-9_\-]*)").unwrap())
156}
157
158fn inspect_cargo_line(
159 line: &str,
160 path: &Path,
161 line_no: u32,
162 md: &CargoMetadata,
163 out: &mut Vec<Divergence>,
164) {
165 if cargo_command_re().find(line).is_none() {
166 return;
167 }
168 if md.packages.is_empty() && md.bins.is_empty() {
170 return;
171 }
172
173 if let Some(caps) = package_arg_re().captures(line) {
174 let name = caps.get(1).unwrap().as_str();
175 if !md.knows_package(name) {
176 out.push(Divergence {
177 rule: RuleId::GhostCommand,
178 severity: Severity::Warning,
179 location: Location::new(path.to_path_buf(), line_no),
180 stated: format!("CI runs `cargo` against package `{name}`"),
181 reality: format!("`{name}` is not a member of the workspace"),
182 risk: "CI exercises a target that no longer exists; the step is a no-op at best."
183 .to_string(),
184 attribution: None,
185
186 });
187 }
188 }
189
190 if let Some(caps) = bin_arg_re().captures(line) {
191 let name = caps.get(1).unwrap().as_str();
192 if !md.knows_bin(name) {
193 out.push(Divergence {
194 rule: RuleId::GhostCommand,
195 severity: Severity::Warning,
196 location: Location::new(path.to_path_buf(), line_no),
197 stated: format!("CI builds/runs bin `{name}`"),
198 reality: format!("no bin target named `{name}` in the workspace"),
199 risk: "CI step refers to a bin that doesn't exist.".to_string(),
200 attribution: None,
201
202 });
203 }
204 }
205}
206
207#[cfg(test)]
208mod tests_inner {
209 use super::*;
210 use std::path::PathBuf;
211
212 fn ctx_with(path: PathBuf, mk: bool, yml: bool) -> ProjectContext {
213 let tmp = path.parent().unwrap().to_path_buf();
214 let mut ctx = ProjectContext::new(tmp);
215 if mk {
216 ctx.makefile_files.push(path);
217 } else if yml {
218 ctx.yaml_files.push(path);
219 }
220 ctx
221 }
222
223 fn md(packages: &[&str], bins: &[&str]) -> CargoMetadata {
224 CargoMetadata {
225 packages: packages.iter().map(|s| s.to_string()).collect(),
226 bins: bins.iter().map(|s| s.to_string()).collect(),
227 }
228 }
229
230 #[test]
231 fn flags_unknown_package_in_makefile() {
232 let tmp = tempfile::tempdir().unwrap();
233 let mk = tmp.path().join("Makefile");
234 std::fs::write(&mk, "test:\n\tcargo test --package legacy_crate\n").unwrap();
235
236 let ctx = ctx_with(mk, true, false);
237 let analyzer = CiAnalyzer::with_metadata(md(&["spec-drift"], &["spec-drift"]));
238 let divs = analyzer.analyze(&ctx);
239
240 assert_eq!(divs.len(), 1);
241 assert_eq!(divs[0].rule, RuleId::GhostCommand);
242 assert_eq!(divs[0].severity, Severity::Warning);
243 }
244
245 #[test]
246 fn accepts_known_package() {
247 let tmp = tempfile::tempdir().unwrap();
248 let mk = tmp.path().join("Makefile");
249 std::fs::write(&mk, "test:\n\tcargo test --package spec-drift\n").unwrap();
250
251 let ctx = ctx_with(mk, true, false);
252 let analyzer = CiAnalyzer::with_metadata(md(&["spec-drift"], &[]));
253 assert!(analyzer.analyze(&ctx).is_empty());
254 }
255
256 #[test]
257 fn flags_unknown_bin_in_workflow() {
258 let tmp = tempfile::tempdir().unwrap();
259 let workflows = tmp.path().join(".github").join("workflows");
260 std::fs::create_dir_all(&workflows).unwrap();
261 let yml = workflows.join("ci.yml");
262 std::fs::write(&yml, "jobs:\n t:\n steps:\n - run: cargo run --bin legacy\n").unwrap();
263
264 let mut ctx = ProjectContext::new(tmp.path());
265 ctx.yaml_files.push(yml);
266 let analyzer = CiAnalyzer::with_metadata(md(&["spec-drift"], &["spec-drift"]));
267 let divs = analyzer.analyze(&ctx);
268
269 assert_eq!(divs.len(), 1);
270 assert_eq!(divs[0].rule, RuleId::GhostCommand);
271 }
272
273 #[test]
274 fn ignores_yaml_outside_github_workflows() {
275 let tmp = tempfile::tempdir().unwrap();
276 let yml = tmp.path().join("deploy.yml");
277 std::fs::write(&yml, "run: cargo run --bin legacy\n").unwrap();
278
279 let mut ctx = ProjectContext::new(tmp.path());
280 ctx.yaml_files.push(yml);
281 let analyzer = CiAnalyzer::with_metadata(md(&["spec-drift"], &[]));
282 assert!(analyzer.analyze(&ctx).is_empty());
283 }
284
285 #[test]
286 fn noop_when_metadata_unavailable() {
287 let tmp = tempfile::tempdir().unwrap();
288 let mk = tmp.path().join("Makefile");
289 std::fs::write(&mk, "cargo test --package ghost\n").unwrap();
290 let ctx = ctx_with(mk, true, false);
291 let analyzer = CiAnalyzer::with_metadata(CargoMetadata::default());
293 assert!(analyzer.analyze(&ctx).is_empty());
294 }
295}