1use crate::config::normalize_platform_path;
4use crate::model::{
5 stable_fingerprint, stable_fingerprint_parts, DefinitionComparison, Finding, Suppression,
6};
7use crate::resource_limits::{read_utf8, MAX_BASELINE_INPUT_BYTES};
8use anyhow::{bail, Context, Result};
9use serde::{Deserialize, Serialize};
10use std::collections::{BTreeMap, BTreeSet};
11use std::fs;
12use std::path::{Path, PathBuf};
13use std::process::Command;
14
15pub fn git_changed_files(root: &Path, base: &str) -> Result<BTreeSet<PathBuf>> {
17 let base_oid = resolve_commit(root, base)?;
18 let tracked = git_paths(
19 root,
20 &[
21 "diff",
22 "--relative",
23 "--name-only",
24 "-z",
25 "--diff-filter=ACMR",
26 &base_oid,
27 "--",
28 ],
29 &format!("git diff against `{base}` failed"),
30 )?;
31 let untracked = git_paths(
32 root,
33 &["ls-files", "--others", "--exclude-standard", "-z", "--"],
34 "git untracked-file discovery failed",
35 )?;
36 Ok(tracked
37 .into_iter()
38 .chain(untracked)
39 .map(|path| root.join(path))
40 .collect())
41}
42
43fn resolve_commit(root: &Path, base: &str) -> Result<String> {
44 if base.trim().is_empty() || base.starts_with('-') {
45 bail!("invalid --changed-since revision `{base}`");
46 }
47 let revision = format!("{base}^{{commit}}");
48 let output = Command::new("git")
49 .arg("-C")
50 .arg(root)
51 .args(["rev-parse", "--verify", "--end-of-options"])
52 .arg(&revision)
53 .output()
54 .with_context(|| "failed to resolve the --changed-since revision")?;
55 if !output.status.success() {
56 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
57 bail!("could not resolve --changed-since revision `{base}`: {stderr}");
58 }
59 let oid = String::from_utf8(output.stdout)
60 .context("git returned a non-UTF-8 commit object id")?
61 .trim()
62 .to_owned();
63 if oid.is_empty() || !oid.bytes().all(|byte| byte.is_ascii_hexdigit()) {
64 bail!("git returned an invalid commit object id for `{base}`");
65 }
66 Ok(oid)
67}
68
69pub fn ensure_changed_root_is_trackable(root: &Path) -> Result<()> {
71 let root = normalize_platform_path(root.to_path_buf());
72 let repository = Command::new("git")
73 .arg("-C")
74 .arg(&root)
75 .args(["rev-parse", "--show-toplevel"])
76 .output()
77 .with_context(|| "failed to locate the Git repository for changed-files mode")?;
78 if !repository.status.success() {
79 bail!(
80 "changed-files mode requires a Git repository: {}",
81 String::from_utf8_lossy(&repository.stderr).trim()
82 );
83 }
84 let repository = normalize_platform_path(
85 PathBuf::from(
86 String::from_utf8(repository.stdout)
87 .context("git returned a non-UTF-8 repository path")?
88 .trim(),
89 )
90 .canonicalize()
91 .with_context(|| "failed to canonicalize the Git repository root")?,
92 );
93 if root == repository {
94 return Ok(());
95 }
96 let relative = root.strip_prefix(&repository).with_context(|| {
97 format!(
98 "target {} is outside Git repository {}",
99 root.display(),
100 repository.display()
101 )
102 })?;
103 let ignored = Command::new("git")
104 .arg("-C")
105 .arg(&repository)
106 .args(["check-ignore", "--quiet", "--"])
107 .arg(relative)
108 .status()
109 .with_context(|| "failed to check whether the changed-files target is ignored")?;
110 match ignored.code() {
111 Some(0) => bail!(
112 "changed-files target {} is ignored by Git and cannot be analyzed incrementally",
113 root.display()
114 ),
115 Some(1) => Ok(()),
116 _ => bail!("git check-ignore failed for target {}", root.display()),
117 }
118}
119
120fn git_paths(root: &Path, arguments: &[&str], failure: &str) -> Result<Vec<PathBuf>> {
121 let output = Command::new("git")
122 .arg("-C")
123 .arg(root)
124 .args(["-c", "core.quotePath=false"])
125 .args(arguments)
126 .output()
127 .with_context(|| "failed to run git for changed-files mode")?;
128 if !output.status.success() {
129 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
130 bail!("{failure}: {stderr}");
131 }
132 output
133 .stdout
134 .split(|byte| *byte == b'\0')
135 .filter(|path| !path.is_empty())
136 .map(path_from_git_bytes)
137 .collect()
138}
139
140#[cfg(unix)]
141fn path_from_git_bytes(bytes: &[u8]) -> Result<PathBuf> {
142 use std::ffi::OsStr;
143 use std::os::unix::ffi::OsStrExt;
144
145 Ok(PathBuf::from(OsStr::from_bytes(bytes)))
146}
147
148#[cfg(not(unix))]
149fn path_from_git_bytes(bytes: &[u8]) -> Result<PathBuf> {
150 Ok(PathBuf::from(
151 std::str::from_utf8(bytes).context("git returned a non-UTF-8 file name")?,
152 ))
153}
154
155pub fn retain_changed_findings(findings: &mut Vec<Finding>, changed: &BTreeSet<PathBuf>) {
157 findings.retain(|finding| {
158 changed.contains(&finding.primary.path)
159 || finding
160 .related
161 .iter()
162 .any(|location| changed.contains(&location.path))
163 });
164}
165
166pub const BASELINE_SCHEMA_VERSION: u32 = 2;
168
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170#[serde(rename_all = "camelCase")]
171#[non_exhaustive]
173pub struct BaselineFile {
174 pub schema_version: u32,
176 pub fingerprints: BTreeMap<String, usize>,
178}
179
180impl BaselineFile {
181 pub fn new(fingerprints: BTreeMap<String, usize>) -> Self {
183 Self {
184 schema_version: BASELINE_SCHEMA_VERSION,
185 fingerprints,
186 }
187 }
188
189 fn empty() -> Self {
190 Self::new(BTreeMap::new())
191 }
192}
193
194impl Default for BaselineFile {
195 fn default() -> Self {
196 Self::new(BTreeMap::new())
197 }
198}
199
200#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
201#[non_exhaustive]
203pub struct BaselineOutcome {
204 pub suppressed: usize,
206 pub new_findings: usize,
208 pub added: usize,
210 pub removed: usize,
212}
213
214pub fn apply_baseline(findings: &mut [Finding], baseline_path: &Path) -> Result<BaselineOutcome> {
219 let baseline = load_baseline(baseline_path)?;
220 apply_loaded_baseline(findings, baseline_path, &baseline)
221}
222
223pub fn update_baseline(findings: &mut [Finding], baseline_path: &Path) -> Result<BaselineOutcome> {
225 let previous = if baseline_path.is_file() {
226 load_baseline_for_update(baseline_path)?
227 } else {
228 BaselineFile::empty()
229 };
230 let current = build_baseline(findings);
231 let added = count_added(&previous.fingerprints, ¤t.fingerprints);
232 let removed = count_added(¤t.fingerprints, &previous.fingerprints);
233 save_baseline(baseline_path, ¤t)?;
234 let mut outcome = apply_loaded_baseline(findings, baseline_path, ¤t)?;
235 outcome.added = added;
236 outcome.removed = removed;
237 Ok(outcome)
238}
239
240fn load_baseline(baseline_path: &Path) -> Result<BaselineFile> {
241 let baseline = parse_baseline(baseline_path)?;
242 if baseline.schema_version != BASELINE_SCHEMA_VERSION {
243 bail!(
244 "unsupported baseline schema version `{}` (expected `{}`); regenerate it with --update-baseline",
245 baseline.schema_version,
246 BASELINE_SCHEMA_VERSION
247 );
248 }
249 Ok(baseline)
250}
251
252fn load_baseline_for_update(baseline_path: &Path) -> Result<BaselineFile> {
253 let baseline = parse_baseline(baseline_path)?;
254 if baseline.schema_version > BASELINE_SCHEMA_VERSION {
255 bail!(
256 "baseline schema version `{}` is newer than supported version `{}` and cannot be replaced safely",
257 baseline.schema_version,
258 BASELINE_SCHEMA_VERSION
259 );
260 }
261 Ok(baseline)
262}
263
264fn parse_baseline(baseline_path: &Path) -> Result<BaselineFile> {
265 let text = read_utf8(baseline_path, "baseline", MAX_BASELINE_INPUT_BYTES)?;
266 serde_json::from_str(&text)
268 .with_context(|| format!("failed to parse baseline {}", baseline_path.display()))
269}
270
271fn save_baseline(path: &Path, baseline: &BaselineFile) -> Result<()> {
272 if let Some(parent) = path.parent() {
273 fs::create_dir_all(parent)
274 .with_context(|| format!("failed to create baseline directory {}", parent.display()))?;
275 }
276 let text = serde_json::to_string_pretty(baseline).context("failed to serialize baseline")?;
277 fs::write(path, format!("{text}\n"))
278 .with_context(|| format!("failed to write baseline {}", path.display()))
279}
280
281fn build_baseline(findings: &[Finding]) -> BaselineFile {
282 let mut baseline = BaselineFile::empty();
283 for finding in findings.iter().filter(|finding| finding.is_active()) {
284 *baseline
285 .fingerprints
286 .entry(finding_fingerprint(finding))
287 .or_default() += 1;
288 }
289 baseline
290}
291
292fn apply_loaded_baseline(
293 findings: &mut [Finding],
294 baseline_path: &Path,
295 baseline: &BaselineFile,
296) -> Result<BaselineOutcome> {
297 let mut allowance = baseline.fingerprints.clone();
298 let mut outcome = BaselineOutcome::default();
299 for finding in findings {
300 if !finding.is_active() {
301 continue;
302 }
303 let fingerprint = finding_fingerprint(finding);
304 match allowance.get_mut(&fingerprint) {
305 Some(remaining) if *remaining > 0 => {
306 *remaining -= 1;
307 finding.suppression = Some(Suppression {
308 reason: format!("present in baseline {}", baseline_path.display()),
309 });
310 outcome.suppressed += 1;
311 }
312 _ => outcome.new_findings += 1,
313 }
314 }
315 Ok(outcome)
316}
317
318pub fn finding_fingerprint(finding: &Finding) -> String {
320 if let Some(cluster) = &finding.evidence.cluster {
321 let rule = finding.rule.to_string();
322 let mut fingerprints = cluster
323 .definition_fingerprints
324 .iter()
325 .map(String::as_str)
326 .collect::<Vec<_>>();
327 fingerprints.sort_unstable();
328 return stable_fingerprint_parts(std::iter::once(rule.as_str()).chain(fingerprints));
329 }
330 let semantic = finding
331 .evidence
332 .comparison
333 .as_ref()
334 .map(comparison_fingerprint_input)
335 .unwrap_or_else(|| {
336 format!(
337 "{}\u{0}{}\u{0}{}",
338 finding.message,
339 finding.evidence.matcher_difference,
340 finding.evidence.handler_evidence
341 )
342 });
343 stable_fingerprint(&format!("{}\u{0}{semantic}", finding.rule))
344}
345
346fn comparison_fingerprint_input(comparison: &DefinitionComparison) -> String {
347 let mut sides = [
348 comparison.left_fingerprint.clone(),
349 comparison.right_fingerprint.clone(),
350 ];
351 sides.sort();
352 sides.join("\u{0}")
353}
354
355fn count_added(previous: &BTreeMap<String, usize>, current: &BTreeMap<String, usize>) -> usize {
356 current
357 .iter()
358 .map(|(fingerprint, count)| {
359 count.saturating_sub(previous.get(fingerprint).copied().unwrap_or_default())
360 })
361 .sum()
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367 use crate::model::{DefinitionCluster, FindingEvidence, Rule, Severity, SourceLocation};
368
369 fn initialize_repository(root: &Path) {
370 for arguments in [
371 ["init", "-q"].as_slice(),
372 ["config", "user.email", "test@example.com"].as_slice(),
373 ["config", "user.name", "Test"].as_slice(),
374 ["add", "."].as_slice(),
375 ["commit", "-qm", "initial"].as_slice(),
376 ] {
377 assert!(Command::new("git")
378 .args(arguments)
379 .current_dir(root)
380 .status()
381 .unwrap()
382 .success());
383 }
384 }
385
386 fn finding(root: &Path, file: &str) -> Finding {
387 Finding {
388 rule: Rule::DuplicateMatcher,
389 severity: Severity::Error,
390 message: "duplicate".to_owned(),
391 primary: SourceLocation::new(root.join(file), 2, 1, 2, 10),
392 related: vec![SourceLocation::new(
393 root.join("steps/shared.ts"),
394 4,
395 1,
396 4,
397 10,
398 )],
399 evidence: FindingEvidence {
400 matcher_similarity: Some(1.0),
401 handler_similarity: Some(0.5),
402 matcher_difference: "same".to_owned(),
403 handler_evidence: "different".to_owned(),
404 comparison: None,
405 cluster: None,
406 },
407 suggested_action: "consolidate".to_owned(),
408 suppression: None,
409 }
410 }
411
412 #[test]
413 fn changed_mode_keeps_findings_related_to_a_changed_file() {
414 let root = Path::new("/repo");
415 let mut findings = vec![
416 finding(root, "steps/changed.ts"),
417 finding(root, "steps/old.ts"),
418 ];
419 let changed = BTreeSet::from([root.join("steps/changed.ts")]);
420 retain_changed_findings(&mut findings, &changed);
421 assert_eq!(findings.len(), 1);
422 assert!(findings[0].primary.path.ends_with("changed.ts"));
423 }
424
425 #[test]
426 fn cluster_fingerprints_are_location_and_member_order_independent() {
427 let root = Path::new("/repo");
428 let mut original = finding(root, "steps/a.ts");
429 original.evidence.cluster = Some(DefinitionCluster {
430 member_count: 3,
431 definition_fingerprints: vec!["charlie".into(), "alpha".into(), "bravo".into()],
432 pair_findings_collapsed: 2,
433 members_truncated: false,
434 });
435 let mut moved = finding(root, "moved/renamed.ts");
436 moved.evidence.cluster = Some(DefinitionCluster {
437 member_count: 3,
438 definition_fingerprints: vec!["bravo".into(), "charlie".into(), "alpha".into()],
439 pair_findings_collapsed: 99,
440 members_truncated: false,
441 });
442
443 assert_eq!(finding_fingerprint(&original), finding_fingerprint(&moved));
444 moved
445 .evidence
446 .cluster
447 .as_mut()
448 .unwrap()
449 .definition_fingerprints
450 .push("delta".into());
451 assert_ne!(finding_fingerprint(&original), finding_fingerprint(&moved));
452 }
453
454 #[test]
455 fn changed_files_are_relative_to_a_subdirectory_and_include_untracked_files() {
456 let directory = tempfile::tempdir().unwrap();
457 let root = directory.path();
458 fs::create_dir_all(root.join("packages/e2e/steps")).unwrap();
459 fs::write(root.join("packages/e2e/steps/träcked.ts"), "before").unwrap();
460 initialize_repository(root);
461 fs::write(root.join("packages/e2e/steps/träcked.ts"), "after").unwrap();
462 fs::write(root.join("packages/e2e/steps/ new step.ts"), "new").unwrap();
463 #[cfg(unix)]
464 fs::write(root.join("packages/e2e/steps/trailing step.ts "), "new").unwrap();
465
466 let subdirectory = root.join("packages/e2e");
467 let changed = git_changed_files(&subdirectory, "HEAD").unwrap();
468 let mut expected = BTreeSet::from([
469 subdirectory.join("steps/ new step.ts"),
470 subdirectory.join("steps/träcked.ts"),
471 ]);
472 #[cfg(unix)]
473 expected.insert(subdirectory.join("steps/trailing step.ts "));
474 assert_eq!(changed, expected);
475 }
476
477 #[cfg(unix)]
478 #[test]
479 fn changed_files_preserve_newlines_in_unix_names() {
480 let directory = tempfile::tempdir().unwrap();
481 let root = directory.path();
482 let steps = root.join("steps");
483 fs::create_dir_all(&steps).unwrap();
484 let newline = steps.join("line\nbreak.ts");
485 fs::write(&newline, "before").unwrap();
486 initialize_repository(root);
487 fs::write(&newline, "after").unwrap();
488
489 let changed = git_changed_files(root, "HEAD").unwrap();
490 assert_eq!(changed, BTreeSet::from([newline]));
491 }
492
493 #[cfg(unix)]
494 #[test]
495 fn git_path_decoder_preserves_non_utf8_unix_bytes() {
496 use std::os::unix::ffi::OsStrExt;
497
498 let bytes = b"steps/non-utf8-\xff.ts";
499 let path = path_from_git_bytes(bytes).unwrap();
500 assert_eq!(path.as_os_str().as_bytes(), bytes);
501 }
502
503 #[cfg(target_os = "linux")]
504 #[test]
505 fn changed_files_preserve_non_utf8_linux_names() {
506 use std::ffi::OsString;
507 use std::os::unix::ffi::OsStringExt;
508
509 let directory = tempfile::tempdir().unwrap();
510 let root = directory.path();
511 let steps = root.join("steps");
512 fs::create_dir_all(&steps).unwrap();
513 let non_utf8 = steps.join(OsString::from_vec(b"non-utf8-\xff.ts".to_vec()));
514 fs::write(&non_utf8, "before").unwrap();
515 initialize_repository(root);
516 fs::write(&non_utf8, "after").unwrap();
517
518 let changed = git_changed_files(root, "HEAD").unwrap();
519 assert_eq!(changed, BTreeSet::from([non_utf8]));
520 }
521
522 #[test]
523 fn semantic_baseline_survives_location_changes_and_enforces_multiplicity() {
524 let directory = tempfile::tempdir().unwrap();
525 let root = directory.path();
526 let baseline_path = root.join("baseline.json");
527 let mut accepted = vec![finding(root, "steps/original.ts")];
528 let update = update_baseline(&mut accepted, &baseline_path).unwrap();
529 assert_eq!(update.added, 1);
530 assert_eq!(update.removed, 0);
531 assert_eq!(update.suppressed, 1);
532
533 let mut current = vec![
534 finding(root, "moved/renamed.ts"),
535 finding(root, "steps/third-copy.ts"),
536 ];
537 current[0].primary.line = 200;
538 let outcome = apply_baseline(&mut current, &baseline_path).unwrap();
539 assert_eq!(outcome.suppressed, 1);
540 assert_eq!(outcome.new_findings, 1);
541 assert!(!current[0].is_active());
542 assert!(current[1].is_active());
543
544 let baseline: BaselineFile =
545 serde_json::from_str(&fs::read_to_string(&baseline_path).unwrap()).unwrap();
546 assert_eq!(baseline.schema_version, BASELINE_SCHEMA_VERSION);
547 assert_eq!(baseline.fingerprints.values().sum::<usize>(), 1);
548 }
549
550 #[test]
551 fn changed_mode_validates_revisions_repositories_and_ignored_roots() {
552 let directory = tempfile::tempdir().unwrap();
553 fs::write(directory.path().join("tracked.ts"), "initial").unwrap();
554 initialize_repository(directory.path());
555 assert!(
556 ensure_changed_root_is_trackable(&directory.path().canonicalize().unwrap()).is_ok()
557 );
558 for revision in ["", " ", "--all", "missing-revision"] {
559 assert!(
560 resolve_commit(directory.path(), revision).is_err(),
561 "{revision}"
562 );
563 }
564 assert!(git_paths(directory.path(), &["not-a-command"], "expected failure").is_err());
565
566 let ignored = directory.path().join("ignored");
567 fs::create_dir(&ignored).unwrap();
568 fs::write(directory.path().join(".gitignore"), "ignored/\n").unwrap();
569 assert!(
570 ensure_changed_root_is_trackable(&ignored.canonicalize().unwrap())
571 .unwrap_err()
572 .to_string()
573 .contains("ignored by Git")
574 );
575
576 let outside = tempfile::tempdir().unwrap();
577 assert!(ensure_changed_root_is_trackable(outside.path()).is_err());
578 }
579
580 #[test]
581 fn changed_mode_keeps_findings_with_changed_related_locations() {
582 let root = Path::new("/repo");
583 let mut findings = vec![finding(root, "steps/old.ts")];
584 let changed = BTreeSet::from([root.join("steps/shared.ts")]);
585 retain_changed_findings(&mut findings, &changed);
586 assert_eq!(findings.len(), 1);
587 }
588
589 #[test]
590 fn baseline_validation_counts_removals_and_ignores_suppressed_findings() {
591 let directory = tempfile::tempdir().unwrap();
592 let baseline_path = directory.path().join("nested/baseline.json");
593 let mut initial = vec![
594 finding(directory.path(), "one.ts"),
595 finding(directory.path(), "two.ts"),
596 ];
597 initial[1].message = "second duplicate".to_owned();
598 update_baseline(&mut initial, &baseline_path).unwrap();
599
600 let mut current = vec![finding(directory.path(), "one.ts")];
601 current[0].suppression = Some(Suppression {
602 reason: "already accepted elsewhere".to_owned(),
603 });
604 let outcome = update_baseline(&mut current, &baseline_path).unwrap();
605 assert_eq!(outcome.added, 0);
606 assert_eq!(outcome.removed, 2);
607 assert_eq!(outcome.suppressed, 0);
608
609 fs::write(&baseline_path, r#"{"schemaVersion":99,"fingerprints":{}}"#).unwrap();
610 assert!(apply_baseline(&mut [], &baseline_path)
611 .unwrap_err()
612 .to_string()
613 .contains("unsupported baseline schema"));
614 fs::write(&baseline_path, "not json").unwrap();
615 assert!(apply_baseline(&mut [], &baseline_path)
616 .unwrap_err()
617 .to_string()
618 .contains("failed to parse baseline"));
619 }
620
621 #[test]
622 fn comparison_fingerprints_are_independent_of_pair_order() {
623 let comparison = DefinitionComparison {
624 left_fingerprint: "left".to_owned(),
625 right_fingerprint: "right".to_owned(),
626 left_matcher: String::new(),
627 right_matcher: String::new(),
628 left_handler: String::new(),
629 right_handler: String::new(),
630 matcher_diff: crate::model::MatcherDiff {
631 prefix: String::new(),
632 left_change: String::new(),
633 right_change: String::new(),
634 suffix: String::new(),
635 },
636 };
637 let mut reversed = comparison.clone();
638 std::mem::swap(
639 &mut reversed.left_fingerprint,
640 &mut reversed.right_fingerprint,
641 );
642 assert_eq!(
643 comparison_fingerprint_input(&comparison),
644 comparison_fingerprint_input(&reversed)
645 );
646 assert_eq!(
647 count_added(
648 &BTreeMap::from([("a".to_owned(), 3)]),
649 &BTreeMap::from([("a".to_owned(), 1)])
650 ),
651 0
652 );
653 }
654}