1use 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
12pub 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
65pub 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
134pub 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
145pub const BASELINE_SCHEMA_VERSION: u32 = 1;
147
148#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(rename_all = "camelCase")]
150pub struct BaselineFile {
152 pub schema_version: u32,
154 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)]
168pub struct BaselineOutcome {
170 pub suppressed: usize,
172 pub new_findings: usize,
174 pub added: usize,
176 pub removed: usize,
178}
179
180pub 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
189pub 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, ¤t.fingerprints);
198 let removed = count_added(¤t.fingerprints, &previous.fingerprints);
199 save_baseline(baseline_path, ¤t)?;
200 let mut outcome = apply_loaded_baseline(findings, baseline_path, ¤t)?;
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
268pub 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}