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 #[derive(serde::Deserialize)]
45 struct RawMetadata {
46 packages: Vec<RawPackage>,
47 }
48 #[derive(serde::Deserialize)]
49 struct RawPackage {
50 name: String,
51 targets: Vec<RawTarget>,
52 }
53 #[derive(serde::Deserialize)]
54 struct RawTarget {
55 name: String,
56 kind: Vec<String>,
57 }
58
59 let out = Command::new("cargo")
60 .current_dir(manifest_dir)
61 .args(["metadata", "--format-version=1", "--no-deps"])
62 .output();
63 let Ok(out) = out else {
64 return Self::default();
65 };
66 if !out.status.success() {
67 return Self::default();
68 }
69 let Ok(md) = serde_json::from_slice::<RawMetadata>(&out.stdout) else {
70 return Self::default();
71 };
72
73 let mut packages = Vec::new();
74 let mut bins = Vec::new();
75 for pkg in &md.packages {
76 packages.push(pkg.name.clone());
77 for t in &pkg.targets {
78 if t.kind.iter().any(|k| k == "bin") {
79 bins.push(t.name.clone());
80 }
81 }
82 }
83 Self { packages, bins }
84 }
85}
86
87impl DriftAnalyzer for CiAnalyzer {
88 fn analyze(&self, ctx: &ProjectContext) -> Vec<Divergence> {
89 let metadata = self
90 .metadata_override
91 .clone()
92 .unwrap_or_else(|| CargoMetadata::load(&ctx.root));
93
94 let mut out = Vec::new();
95
96 for mk in &ctx.makefile_files {
98 let src = match std::fs::read_to_string(mk) {
99 Ok(s) => s,
100 Err(e) => {
101 eprintln!("spec-drift: skipping {} (ci): {e}", mk.display());
102 continue;
103 }
104 };
105 for (idx, line) in src.lines().enumerate() {
106 let line_no = (idx + 1) as u32;
107 let trimmed = line.trim_start_matches(['\t', ' ']);
108 let trimmed = trimmed.strip_prefix('@').unwrap_or(trimmed);
110 inspect_cargo_line(trimmed, mk, line_no, &metadata, &mut out);
111 }
112 }
113
114 for yaml in &ctx.yaml_files {
116 let src = match std::fs::read_to_string(yaml) {
117 Ok(s) => s,
118 Err(e) => {
119 eprintln!("spec-drift: skipping {} (ci): {e}", yaml.display());
120 continue;
121 }
122 };
123 if !is_workflow_path(yaml) {
124 continue;
125 }
126 for (idx, line) in src.lines().enumerate() {
127 let line_no = (idx + 1) as u32;
128 inspect_cargo_line(line, yaml, line_no, &metadata, &mut out);
129 }
130 }
131
132 out
133 }
134}
135
136fn is_workflow_path(path: &Path) -> bool {
137 path.components().any(|c| c.as_os_str() == ".github")
140 && path.components().any(|c| c.as_os_str() == "workflows")
141}
142
143fn cargo_command_re() -> &'static Regex {
144 static RE: OnceLock<Regex> = OnceLock::new();
145 RE.get_or_init(|| Regex::new(r"(?:^|\s)cargo\s+([a-zA-Z][a-zA-Z0-9\-]*)\b").unwrap())
146}
147
148fn package_arg_re() -> &'static Regex {
149 static RE: OnceLock<Regex> = OnceLock::new();
150 RE.get_or_init(|| Regex::new(r"(?:--package|-p)[ =]([A-Za-z_][A-Za-z0-9_\-]*)").unwrap())
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 if let Some(caps) = bin_arg_re().captures(line) {
190 let name = caps.get(1).unwrap().as_str();
191 if !md.knows_bin(name) {
192 out.push(Divergence {
193 rule: RuleId::GhostCommand,
194 severity: Severity::Warning,
195 location: Location::new(path.to_path_buf(), line_no),
196 stated: format!("CI builds/runs bin `{name}`"),
197 reality: format!("no bin target named `{name}` in the workspace"),
198 risk: "CI step refers to a bin that doesn't exist.".to_string(),
199 attribution: None,
200 });
201 }
202 }
203}
204
205#[cfg(test)]
206mod tests_inner {
207 use super::*;
208 use std::path::PathBuf;
209
210 fn ctx_with(path: PathBuf, mk: bool, yml: bool) -> ProjectContext {
211 let tmp = path.parent().unwrap().to_path_buf();
212 let mut ctx = ProjectContext::new(tmp);
213 if mk {
214 ctx.makefile_files.push(path);
215 } else if yml {
216 ctx.yaml_files.push(path);
217 }
218 ctx
219 }
220
221 fn md(packages: &[&str], bins: &[&str]) -> CargoMetadata {
222 CargoMetadata {
223 packages: packages.iter().map(|s| s.to_string()).collect(),
224 bins: bins.iter().map(|s| s.to_string()).collect(),
225 }
226 }
227
228 #[test]
229 fn flags_unknown_package_in_makefile() {
230 let tmp = tempfile::tempdir().unwrap();
231 let mk = tmp.path().join("Makefile");
232 std::fs::write(&mk, "test:\n\tcargo test --package legacy_crate\n").unwrap();
233
234 let ctx = ctx_with(mk, true, false);
235 let analyzer = CiAnalyzer::with_metadata(md(&["spec-drift"], &["spec-drift"]));
236 let divs = analyzer.analyze(&ctx);
237
238 assert_eq!(divs.len(), 1);
239 assert_eq!(divs[0].rule, RuleId::GhostCommand);
240 assert_eq!(divs[0].severity, Severity::Warning);
241 }
242
243 #[test]
244 fn accepts_known_package() {
245 let tmp = tempfile::tempdir().unwrap();
246 let mk = tmp.path().join("Makefile");
247 std::fs::write(&mk, "test:\n\tcargo test --package spec-drift\n").unwrap();
248
249 let ctx = ctx_with(mk, true, false);
250 let analyzer = CiAnalyzer::with_metadata(md(&["spec-drift"], &[]));
251 assert!(analyzer.analyze(&ctx).is_empty());
252 }
253
254 #[test]
255 fn flags_unknown_bin_in_workflow() {
256 let tmp = tempfile::tempdir().unwrap();
257 let workflows = tmp.path().join(".github").join("workflows");
258 std::fs::create_dir_all(&workflows).unwrap();
259 let yml = workflows.join("ci.yml");
260 std::fs::write(
261 &yml,
262 "jobs:\n t:\n steps:\n - run: cargo run --bin legacy\n",
263 )
264 .unwrap();
265
266 let mut ctx = ProjectContext::new(tmp.path());
267 ctx.yaml_files.push(yml);
268 let analyzer = CiAnalyzer::with_metadata(md(&["spec-drift"], &["spec-drift"]));
269 let divs = analyzer.analyze(&ctx);
270
271 assert_eq!(divs.len(), 1);
272 assert_eq!(divs[0].rule, RuleId::GhostCommand);
273 }
274
275 #[test]
276 fn ignores_yaml_outside_github_workflows() {
277 let tmp = tempfile::tempdir().unwrap();
278 let yml = tmp.path().join("deploy.yml");
279 std::fs::write(&yml, "run: cargo run --bin legacy\n").unwrap();
280
281 let mut ctx = ProjectContext::new(tmp.path());
282 ctx.yaml_files.push(yml);
283 let analyzer = CiAnalyzer::with_metadata(md(&["spec-drift"], &[]));
284 assert!(analyzer.analyze(&ctx).is_empty());
285 }
286
287 #[test]
288 fn noop_when_metadata_unavailable() {
289 let tmp = tempfile::tempdir().unwrap();
290 let mk = tmp.path().join("Makefile");
291 std::fs::write(&mk, "cargo test --package ghost\n").unwrap();
292 let ctx = ctx_with(mk, true, false);
293 let analyzer = CiAnalyzer::with_metadata(CargoMetadata::default());
295 assert!(analyzer.analyze(&ctx).is_empty());
296 }
297}