Skip to main content

cuke_dedup/
modes.rs

1//! Changed-files and existing-findings baseline modes for incremental CI adoption.
2
3use crate::config::normalize_platform_path;
4use crate::model::{stable_fingerprint, DefinitionComparison, Finding, Suppression};
5use anyhow::{bail, Context, Result};
6use serde::{Deserialize, Serialize};
7use std::collections::{BTreeMap, BTreeSet};
8use std::fs;
9use std::path::{Path, PathBuf};
10use std::process::Command;
11
12/// Returns tracked changes since `base` plus untracked files beneath `root`.
13pub fn git_changed_files(root: &Path, base: &str) -> Result<BTreeSet<PathBuf>> {
14    let base_oid = resolve_commit(root, base)?;
15    let tracked = git_paths(
16        root,
17        &[
18            "diff",
19            "--relative",
20            "--name-only",
21            "--diff-filter=ACMR",
22            &base_oid,
23            "--",
24        ],
25        &format!("git diff against `{base}` failed"),
26    )?;
27    let untracked = git_paths(
28        root,
29        &["ls-files", "--others", "--exclude-standard", "--"],
30        "git untracked-file discovery failed",
31    )?;
32    Ok(tracked
33        .into_iter()
34        .chain(untracked)
35        .map(|path| root.join(path))
36        .collect())
37}
38
39fn resolve_commit(root: &Path, base: &str) -> Result<String> {
40    if base.trim().is_empty() || base.starts_with('-') {
41        bail!("invalid --changed-since revision `{base}`");
42    }
43    let revision = format!("{base}^{{commit}}");
44    let output = Command::new("git")
45        .arg("-C")
46        .arg(root)
47        .args(["rev-parse", "--verify", "--end-of-options"])
48        .arg(&revision)
49        .output()
50        .with_context(|| "failed to resolve the --changed-since revision")?;
51    if !output.status.success() {
52        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
53        bail!("could not resolve --changed-since revision `{base}`: {stderr}");
54    }
55    let oid = String::from_utf8(output.stdout)
56        .context("git returned a non-UTF-8 commit object id")?
57        .trim()
58        .to_owned();
59    if oid.is_empty() || !oid.bytes().all(|byte| byte.is_ascii_hexdigit()) {
60        bail!("git returned an invalid commit object id for `{base}`");
61    }
62    Ok(oid)
63}
64
65/// Rejects changed-file mode when the analyzed root itself is excluded by Git.
66pub fn ensure_changed_root_is_trackable(root: &Path) -> Result<()> {
67    let repository = Command::new("git")
68        .arg("-C")
69        .arg(root)
70        .args(["rev-parse", "--show-toplevel"])
71        .output()
72        .with_context(|| "failed to locate the Git repository for changed-files mode")?;
73    if !repository.status.success() {
74        bail!(
75            "changed-files mode requires a Git repository: {}",
76            String::from_utf8_lossy(&repository.stderr).trim()
77        );
78    }
79    let repository = normalize_platform_path(
80        PathBuf::from(
81            String::from_utf8(repository.stdout)
82                .context("git returned a non-UTF-8 repository path")?
83                .trim(),
84        )
85        .canonicalize()
86        .with_context(|| "failed to canonicalize the Git repository root")?,
87    );
88    if root == repository {
89        return Ok(());
90    }
91    let relative = root.strip_prefix(&repository).with_context(|| {
92        format!(
93            "target {} is outside Git repository {}",
94            root.display(),
95            repository.display()
96        )
97    })?;
98    let ignored = Command::new("git")
99        .arg("-C")
100        .arg(&repository)
101        .args(["check-ignore", "--quiet", "--"])
102        .arg(relative)
103        .status()
104        .with_context(|| "failed to check whether the changed-files target is ignored")?;
105    match ignored.code() {
106        Some(0) => bail!(
107            "changed-files target {} is ignored by Git and cannot be analyzed incrementally",
108            root.display()
109        ),
110        Some(1) => Ok(()),
111        _ => bail!("git check-ignore failed for target {}", root.display()),
112    }
113}
114
115fn git_paths(root: &Path, arguments: &[&str], failure: &str) -> Result<Vec<PathBuf>> {
116    let output = Command::new("git")
117        .arg("-C")
118        .arg(root)
119        .args(arguments)
120        .output()
121        .with_context(|| "failed to run git for changed-files mode")?;
122    if !output.status.success() {
123        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
124        bail!("{failure}: {stderr}");
125    }
126    let stdout = String::from_utf8(output.stdout).context("git returned non-UTF-8 file names")?;
127    Ok(stdout
128        .lines()
129        .filter(|line| !line.trim().is_empty())
130        .map(|line| PathBuf::from(line.trim()))
131        .collect())
132}
133
134/// Retains findings whose primary or related location belongs to `changed`.
135pub fn retain_changed_findings(findings: &mut Vec<Finding>, changed: &BTreeSet<PathBuf>) {
136    findings.retain(|finding| {
137        changed.contains(&finding.primary.path)
138            || finding
139                .related
140                .iter()
141                .any(|location| changed.contains(&location.path))
142    });
143}
144
145/// Schema version of the compact, reviewable baseline format.
146pub const BASELINE_SCHEMA_VERSION: u32 = 1;
147
148#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(rename_all = "camelCase")]
150/// Accepted semantic finding fingerprints and their multiplicities.
151pub struct BaselineFile {
152    /// Baseline format version.
153    pub schema_version: u32,
154    /// Stable fingerprint counts, sorted for deterministic pull-request diffs.
155    pub fingerprints: BTreeMap<String, usize>,
156}
157
158impl BaselineFile {
159    fn empty() -> Self {
160        Self {
161            schema_version: BASELINE_SCHEMA_VERSION,
162            fingerprints: BTreeMap::new(),
163        }
164    }
165}
166
167#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
168/// Effect of applying or rewriting a baseline.
169pub struct BaselineOutcome {
170    /// Findings matched and suppressed by the baseline.
171    pub suppressed: usize,
172    /// Active findings beyond the baseline's recorded multiplicities.
173    pub new_findings: usize,
174    /// Fingerprint instances added while updating.
175    pub added: usize,
176    /// Fingerprint instances removed while updating.
177    pub removed: usize,
178}
179
180/// Suppresses findings found in a schema-compatible semantic baseline.
181///
182/// Multiplicity is consumed in finding order, so adding another instance of accepted
183/// duplication remains visible even when its content fingerprint is already present.
184pub fn apply_baseline(findings: &mut [Finding], baseline_path: &Path) -> Result<BaselineOutcome> {
185    let baseline = load_baseline(baseline_path)?;
186    apply_loaded_baseline(findings, baseline_path, &baseline)
187}
188
189/// Rewrites a baseline from the current active findings and suppresses those findings.
190pub fn update_baseline(findings: &mut [Finding], baseline_path: &Path) -> Result<BaselineOutcome> {
191    let previous = if baseline_path.is_file() {
192        load_baseline(baseline_path)?
193    } else {
194        BaselineFile::empty()
195    };
196    let current = build_baseline(findings);
197    let added = count_added(&previous.fingerprints, &current.fingerprints);
198    let removed = count_added(&current.fingerprints, &previous.fingerprints);
199    save_baseline(baseline_path, &current)?;
200    let mut outcome = apply_loaded_baseline(findings, baseline_path, &current)?;
201    outcome.added = added;
202    outcome.removed = removed;
203    Ok(outcome)
204}
205
206fn load_baseline(baseline_path: &Path) -> Result<BaselineFile> {
207    let text = fs::read_to_string(baseline_path)
208        .with_context(|| format!("failed to read baseline {}", baseline_path.display()))?;
209    let baseline: BaselineFile = serde_json::from_str(&text)
210        .with_context(|| format!("failed to parse baseline {}", baseline_path.display()))?;
211    if baseline.schema_version != BASELINE_SCHEMA_VERSION {
212        bail!(
213            "unsupported baseline schema version `{}` (expected `{}`); regenerate it with --update-baseline",
214            baseline.schema_version,
215            BASELINE_SCHEMA_VERSION
216        );
217    }
218    Ok(baseline)
219}
220
221fn save_baseline(path: &Path, baseline: &BaselineFile) -> Result<()> {
222    if let Some(parent) = path.parent() {
223        fs::create_dir_all(parent)
224            .with_context(|| format!("failed to create baseline directory {}", parent.display()))?;
225    }
226    let text = serde_json::to_string_pretty(baseline).context("failed to serialize baseline")?;
227    fs::write(path, format!("{text}\n"))
228        .with_context(|| format!("failed to write baseline {}", path.display()))
229}
230
231fn build_baseline(findings: &[Finding]) -> BaselineFile {
232    let mut baseline = BaselineFile::empty();
233    for finding in findings.iter().filter(|finding| finding.is_active()) {
234        *baseline
235            .fingerprints
236            .entry(finding_fingerprint(finding))
237            .or_default() += 1;
238    }
239    baseline
240}
241
242fn apply_loaded_baseline(
243    findings: &mut [Finding],
244    baseline_path: &Path,
245    baseline: &BaselineFile,
246) -> Result<BaselineOutcome> {
247    let mut allowance = baseline.fingerprints.clone();
248    let mut outcome = BaselineOutcome::default();
249    for finding in findings {
250        if !finding.is_active() {
251            continue;
252        }
253        let fingerprint = finding_fingerprint(finding);
254        match allowance.get_mut(&fingerprint) {
255            Some(remaining) if *remaining > 0 => {
256                *remaining -= 1;
257                finding.suppression = Some(Suppression {
258                    reason: format!("present in baseline {}", baseline_path.display()),
259                });
260                outcome.suppressed += 1;
261            }
262            _ => outcome.new_findings += 1,
263        }
264    }
265    Ok(outcome)
266}
267
268/// Returns a location-independent fingerprint for baseline comparison.
269pub fn finding_fingerprint(finding: &Finding) -> String {
270    let semantic = finding
271        .evidence
272        .comparison
273        .as_ref()
274        .map(comparison_fingerprint_input)
275        .unwrap_or_else(|| {
276            format!(
277                "{}\u{0}{}\u{0}{}",
278                finding.message,
279                finding.evidence.matcher_difference,
280                finding.evidence.handler_evidence
281            )
282        });
283    stable_fingerprint(&format!("{}\u{0}{semantic}", finding.rule))
284}
285
286fn comparison_fingerprint_input(comparison: &DefinitionComparison) -> String {
287    let mut sides = [
288        comparison.left_fingerprint.clone(),
289        comparison.right_fingerprint.clone(),
290    ];
291    sides.sort();
292    sides.join("\u{0}")
293}
294
295fn count_added(previous: &BTreeMap<String, usize>, current: &BTreeMap<String, usize>) -> usize {
296    current
297        .iter()
298        .map(|(fingerprint, count)| {
299            count.saturating_sub(previous.get(fingerprint).copied().unwrap_or_default())
300        })
301        .sum()
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use crate::model::{FindingEvidence, Rule, Severity, SourceLocation};
308
309    fn finding(root: &Path, file: &str) -> Finding {
310        Finding {
311            rule: Rule::DuplicateMatcher,
312            severity: Severity::Error,
313            message: "duplicate".to_owned(),
314            primary: SourceLocation::new(root.join(file), 2, 1, 2, 10),
315            related: vec![SourceLocation::new(
316                root.join("steps/shared.ts"),
317                4,
318                1,
319                4,
320                10,
321            )],
322            evidence: FindingEvidence {
323                matcher_similarity: Some(1.0),
324                handler_similarity: Some(0.5),
325                matcher_difference: "same".to_owned(),
326                handler_evidence: "different".to_owned(),
327                comparison: None,
328            },
329            suggested_action: "consolidate".to_owned(),
330            suppression: None,
331        }
332    }
333
334    #[test]
335    fn changed_mode_keeps_findings_related_to_a_changed_file() {
336        let root = Path::new("/repo");
337        let mut findings = vec![
338            finding(root, "steps/changed.ts"),
339            finding(root, "steps/old.ts"),
340        ];
341        let changed = BTreeSet::from([root.join("steps/changed.ts")]);
342        retain_changed_findings(&mut findings, &changed);
343        assert_eq!(findings.len(), 1);
344        assert!(findings[0].primary.path.ends_with("changed.ts"));
345    }
346
347    #[test]
348    fn changed_files_are_relative_to_a_subdirectory_and_include_untracked_files() {
349        let directory = tempfile::tempdir().unwrap();
350        let root = directory.path();
351        fs::create_dir_all(root.join("packages/e2e/steps")).unwrap();
352        fs::write(root.join("packages/e2e/steps/tracked.ts"), "before").unwrap();
353        for arguments in [
354            ["init", "-q"].as_slice(),
355            ["config", "user.email", "test@example.com"].as_slice(),
356            ["config", "user.name", "Test"].as_slice(),
357            ["add", "."].as_slice(),
358            ["commit", "-qm", "initial"].as_slice(),
359        ] {
360            assert!(Command::new("git")
361                .args(arguments)
362                .current_dir(root)
363                .status()
364                .unwrap()
365                .success());
366        }
367        fs::write(root.join("packages/e2e/steps/tracked.ts"), "after").unwrap();
368        fs::write(root.join("packages/e2e/steps/new.ts"), "new").unwrap();
369
370        let subdirectory = root.join("packages/e2e");
371        let changed = git_changed_files(&subdirectory, "HEAD").unwrap();
372        assert_eq!(
373            changed,
374            BTreeSet::from([
375                subdirectory.join("steps/new.ts"),
376                subdirectory.join("steps/tracked.ts"),
377            ])
378        );
379    }
380
381    #[test]
382    fn semantic_baseline_survives_location_changes_and_enforces_multiplicity() {
383        let directory = tempfile::tempdir().unwrap();
384        let root = directory.path();
385        let baseline_path = root.join("baseline.json");
386        let mut accepted = vec![finding(root, "steps/original.ts")];
387        let update = update_baseline(&mut accepted, &baseline_path).unwrap();
388        assert_eq!(update.added, 1);
389        assert_eq!(update.removed, 0);
390        assert_eq!(update.suppressed, 1);
391
392        let mut current = vec![
393            finding(root, "moved/renamed.ts"),
394            finding(root, "steps/third-copy.ts"),
395        ];
396        current[0].primary.line = 200;
397        let outcome = apply_baseline(&mut current, &baseline_path).unwrap();
398        assert_eq!(outcome.suppressed, 1);
399        assert_eq!(outcome.new_findings, 1);
400        assert!(!current[0].is_active());
401        assert!(current[1].is_active());
402
403        let baseline: BaselineFile =
404            serde_json::from_str(&fs::read_to_string(&baseline_path).unwrap()).unwrap();
405        assert_eq!(baseline.schema_version, BASELINE_SCHEMA_VERSION);
406        assert_eq!(baseline.fingerprints.values().sum::<usize>(), 1);
407    }
408}