Skip to main content

provable_contracts/lint/
diff.rs

1//! Diff-aware lint: only lint contracts changed since a base ref.
2//!
3//! Uses `git diff --name-only <base_ref>..HEAD -- contracts/` to find
4//! changed YAML files, then expands to include transitive dependents
5//! (contracts that `depend_on` any changed contract).
6//!
7//! Spec: `docs/specifications/sub/lint.md` Section 3
8
9use std::path::Path;
10use std::process::Command;
11
12/// Get contract stems changed since `base_ref`.
13pub fn changed_contracts(contracts_dir: &Path, base_ref: &str) -> Result<Vec<String>, String> {
14    let repo_root = find_repo_root(contracts_dir)?;
15
16    let output = Command::new("git")
17        .args([
18            "diff",
19            "--name-only",
20            &format!("{base_ref}..HEAD"),
21            "--",
22            "contracts/",
23        ])
24        .current_dir(repo_root)
25        .output()
26        .map_err(|e| format!("Failed to run git diff: {e}"))?;
27
28    if !output.status.success() {
29        let stderr = String::from_utf8_lossy(&output.stderr);
30        return Err(format!("git diff failed: {stderr}"));
31    }
32
33    let stdout = String::from_utf8_lossy(&output.stdout);
34    let stems: Vec<String> = stdout
35        .lines()
36        .filter(|line| {
37            Path::new(line)
38                .extension()
39                .is_some_and(|ext| ext.eq_ignore_ascii_case("yaml"))
40        })
41        .filter_map(|line| {
42            Path::new(line)
43                .file_stem()
44                .and_then(|s| s.to_str())
45                .map(ToString::to_string)
46        })
47        .collect();
48
49    Ok(stems)
50}
51
52/// Expand changed stems with transitive dependents.
53///
54/// If contract A changed and contract B has `depends_on: [A]`, then B
55/// is also added to the lint set.
56pub fn expand_dependents(
57    changed: &[String],
58    all_contracts: &[(String, crate::schema::Contract)],
59) -> Vec<String> {
60    let mut expanded: std::collections::HashSet<String> = changed.iter().cloned().collect();
61
62    // Find contracts that depend on any changed contract
63    for (stem, contract) in all_contracts {
64        if expanded.contains(stem) {
65            continue;
66        }
67        for dep in &contract.metadata.depends_on {
68            if expanded.contains(dep) {
69                expanded.insert(stem.clone());
70                break;
71            }
72        }
73    }
74
75    let mut result: Vec<String> = expanded.into_iter().collect();
76    result.sort();
77    result
78}
79
80fn find_repo_root(start: &Path) -> Result<String, String> {
81    let output = Command::new("git")
82        .args(["rev-parse", "--show-toplevel"])
83        .current_dir(start)
84        .output()
85        .map_err(|e| format!("Failed to find git repo root: {e}"))?;
86
87    if !output.status.success() {
88        return Err("Not a git repository".into());
89    }
90
91    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    fn contracts_dir() -> std::path::PathBuf {
99        std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../contracts")
100    }
101
102    #[test]
103    #[ignore] // Requires git repo — skipped in container CI
104    fn changed_contracts_from_head() {
105        // HEAD~0..HEAD should produce empty or small set
106        let result = changed_contracts(&contracts_dir(), "HEAD");
107        assert!(result.is_ok());
108    }
109
110    #[test]
111    fn changed_contracts_invalid_ref() {
112        let result = changed_contracts(&contracts_dir(), "nonexistent-ref-xyz");
113        assert!(result.is_err());
114    }
115
116    #[test]
117    fn expand_dependents_no_deps() {
118        let changed = vec!["softmax-kernel-v1".to_string()];
119        let contracts: Vec<(String, crate::schema::Contract)> = vec![];
120        let expanded = expand_dependents(&changed, &contracts);
121        assert_eq!(expanded, vec!["softmax-kernel-v1".to_string()]);
122    }
123
124    #[test]
125    fn expand_dependents_with_real_contracts() {
126        // Load real contracts and test expand with actual depends_on
127        use crate::lint::gates::load_contracts;
128        let dir = contracts_dir();
129        let (all, _) = load_contracts(&dir);
130
131        // softmax-kernel-v1 has no deps, but attention-kernel-v1 depends on it
132        let changed = vec!["softmax-kernel-v1".to_string()];
133        let expanded = expand_dependents(&changed, &all);
134        assert!(expanded.contains(&"softmax-kernel-v1".to_string()));
135        // Should have at least the original
136        assert!(!expanded.is_empty());
137    }
138
139    #[test]
140    fn expand_dependents_empty_changed() {
141        let changed: Vec<String> = vec![];
142        let contracts: Vec<(String, crate::schema::Contract)> = vec![];
143        let expanded = expand_dependents(&changed, &contracts);
144        assert!(expanded.is_empty());
145    }
146
147    #[test]
148    #[ignore] // Requires git repo — skipped in container CI
149    fn changed_contracts_with_range() {
150        // Use a wide range to guarantee files are returned, exercising the filter closures.
151        // On shallow clones (CI), HEAD~50 may not exist — skip gracefully.
152        let result = changed_contracts(&contracts_dir(), "HEAD~50");
153        match result {
154            Err(e) if e.contains("git diff failed") => {
155                // Shallow clone — skip test
156            }
157            Err(e) => panic!("Unexpected error: {e}"),
158            Ok(stems) => {
159                // All returned stems should be valid (no .yaml extension, no path prefix)
160                for stem in &stems {
161                    assert!(
162                        !stem.to_ascii_lowercase().ends_with(".yaml"),
163                        "Stem should not have extension: {stem}"
164                    );
165                    assert!(
166                        !stem.contains('/'),
167                        "Stem should not have path separator: {stem}"
168                    );
169                }
170            }
171        }
172    }
173
174    #[test]
175    #[ignore] // Requires git repo — skipped in container CI
176    fn find_repo_root_works() {
177        let root = find_repo_root(&contracts_dir());
178        assert!(root.is_ok());
179        assert!(root.unwrap().contains("provable-contracts"));
180    }
181
182    #[test]
183    fn find_repo_root_non_git_dir() {
184        let tmp = tempfile::tempdir().unwrap();
185        let result = find_repo_root(tmp.path());
186        assert!(result.is_err());
187    }
188
189    #[test]
190    fn expand_dependents_transitive() {
191        // Build contracts where B depends on A
192        let a_yaml = r#"
193metadata:
194  version: "1.0.0"
195  description: "A"
196  references: ["Paper"]
197equations:
198  f:
199    formula: "f(x) = x"
200"#;
201        let b_yaml = r#"
202metadata:
203  version: "1.0.0"
204  description: "B"
205  references: ["Paper"]
206  depends_on:
207    - "contract-a"
208equations:
209  g:
210    formula: "g(x) = 2x"
211"#;
212        let c_yaml = r#"
213metadata:
214  version: "1.0.0"
215  description: "C"
216  references: ["Paper"]
217equations:
218  h:
219    formula: "h(x) = 3x"
220"#;
221        let a: crate::schema::Contract = crate::schema::parse_contract_str(a_yaml).unwrap();
222        let b: crate::schema::Contract = crate::schema::parse_contract_str(b_yaml).unwrap();
223        let c: crate::schema::Contract = crate::schema::parse_contract_str(c_yaml).unwrap();
224
225        let all = vec![
226            ("contract-a".to_string(), a),
227            ("contract-b".to_string(), b),
228            ("contract-c".to_string(), c),
229        ];
230
231        // Only A changed — B should be pulled in because it depends on A
232        let changed = vec!["contract-a".to_string()];
233        let expanded = expand_dependents(&changed, &all);
234        assert!(expanded.contains(&"contract-a".to_string()));
235        assert!(expanded.contains(&"contract-b".to_string()));
236        // C has no dependency on A, should not be included
237        assert!(!expanded.contains(&"contract-c".to_string()));
238    }
239
240    #[test]
241    fn expand_dependents_already_in_changed() {
242        // If B is already in changed and depends on A, it shouldn't be duplicated
243        let yaml = r#"
244metadata:
245  version: "1.0.0"
246  description: "X"
247  references: ["Paper"]
248  depends_on:
249    - "other"
250equations:
251  f:
252    formula: "f(x) = x"
253"#;
254        let contract: crate::schema::Contract = crate::schema::parse_contract_str(yaml).unwrap();
255        let all = vec![("dep-contract".to_string(), contract)];
256        let changed = vec!["dep-contract".to_string(), "other".to_string()];
257        let expanded = expand_dependents(&changed, &all);
258        // dep-contract is already in changed, so it should appear exactly once
259        assert_eq!(expanded.iter().filter(|s| *s == "dep-contract").count(), 1);
260    }
261}