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