Skip to main content

spec_drift/analyzers/
ci.rs

1use 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/// CiAnalyzer — enforces the `ghost_command` rule (deterministic).
10///
11/// Strategy: scan Makefile / justfile / GitHub workflow YAMLs for `cargo`
12/// invocations. For each `--package <name>` / `-p <name>` / `--bin <name>`
13/// argument, confirm the target exists in `cargo metadata`. Anything else
14/// (unknown packages, unknown bins) is a ghost command.
15#[derive(Default)]
16pub struct CiAnalyzer {
17    metadata_override: Option<CargoMetadata>,
18}
19
20impl CiAnalyzer {
21    /// Construct with pre-loaded metadata (used by tests so they don't shell out).
22    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        // Makefiles and justfiles — line-oriented.
102        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                // Strip `@` echo prefix common in make recipes.
110                let trimmed = trimmed.strip_prefix('@').unwrap_or(trimmed);
111                inspect_cargo_line(trimmed, mk, line_no, &metadata, &mut out);
112            }
113        }
114
115        // YAML workflows — treat every line containing a cargo invocation.
116        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    // Only scan files inside a `.github/workflows/` directory to avoid noisy
135    // matches in unrelated YAML (k8s manifests, docker-compose, etc.).
136    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    // Skip cargo-generated or unknown metadata — only flag when we have facts.
169    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        // Empty metadata — the analyzer must fail-open, never spurious-positive.
292        let analyzer = CiAnalyzer::with_metadata(CargoMetadata::default());
293        assert!(analyzer.analyze(&ctx).is_empty());
294    }
295}