1use std::path::{Path, PathBuf};
17use std::process::Output;
18use std::sync::OnceLock;
19
20use rustc_hash::{FxHashMap, FxHashSet};
21
22use crate::duplicates::{DuplicationReport, DuplicationStats, families};
23use crate::results::AnalysisResults;
24
25pub type ChangedFilesSpawnHook = fn(&mut std::process::Command) -> std::io::Result<Output>;
32
33static SPAWN_HOOK: OnceLock<ChangedFilesSpawnHook> = OnceLock::new();
34
35pub fn set_spawn_hook(hook: ChangedFilesSpawnHook) {
42 let _ = SPAWN_HOOK.set(hook);
43}
44
45fn spawn_output(command: &mut std::process::Command) -> std::io::Result<Output> {
46 if let Some(hook) = SPAWN_HOOK.get() {
47 hook(command)
48 } else {
49 command.output()
50 }
51}
52
53pub fn validate_git_ref(s: &str) -> Result<&str, String> {
66 if s.is_empty() {
67 return Err("git ref cannot be empty".to_string());
68 }
69 if s.starts_with('-') {
70 return Err("git ref cannot start with '-'".to_string());
71 }
72 let mut in_braces = false;
73 for c in s.chars() {
74 match c {
75 '{' => in_braces = true,
76 '}' => in_braces = false,
77 ':' | ' ' if in_braces => {}
78 c if c.is_ascii_alphanumeric()
79 || matches!(c, '.' | '_' | '-' | '/' | '~' | '^' | '@' | '{' | '}') => {}
80 _ => return Err(format!("git ref contains disallowed character: '{c}'")),
81 }
82 }
83 if in_braces {
84 return Err("git ref has unclosed '{'".to_string());
85 }
86 Ok(s)
87}
88
89#[derive(Debug)]
92pub enum ChangedFilesError {
93 InvalidRef(String),
95 GitMissing(String),
97 NotARepository,
99 GitFailed(String),
101}
102
103impl ChangedFilesError {
104 pub fn describe(&self) -> String {
108 match self {
109 Self::InvalidRef(e) => format!("invalid git ref: {e}"),
110 Self::GitMissing(e) => format!("failed to run git: {e}"),
111 Self::NotARepository => "not a git repository".to_owned(),
112 Self::GitFailed(stderr) => augment_git_failed(stderr),
113 }
114 }
115}
116
117fn augment_git_failed(stderr: &str) -> String {
123 let lower = stderr.to_ascii_lowercase();
124 if lower.contains("not a valid object name")
125 || lower.contains("unknown revision")
126 || lower.contains("ambiguous argument")
127 {
128 format!(
129 "{stderr} (shallow clone? try `git fetch --unshallow`, or set `fetch-depth: 0` on actions/checkout / `GIT_DEPTH: 0` in GitLab CI)"
130 )
131 } else {
132 stderr.to_owned()
133 }
134}
135
136pub fn resolve_git_toplevel(cwd: &Path) -> Result<PathBuf, ChangedFilesError> {
147 let output = spawn_output(&mut git_command(cwd, &["rev-parse", "--show-toplevel"]))
148 .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
149
150 if !output.status.success() {
151 let stderr = String::from_utf8_lossy(&output.stderr);
152 return Err(if stderr.contains("not a git repository") {
153 ChangedFilesError::NotARepository
154 } else {
155 ChangedFilesError::GitFailed(stderr.trim().to_owned())
156 });
157 }
158
159 let raw = String::from_utf8_lossy(&output.stdout);
160 let trimmed = raw.trim();
161 if trimmed.is_empty() {
162 return Err(ChangedFilesError::GitFailed(
163 "git rev-parse --show-toplevel returned empty output".to_owned(),
164 ));
165 }
166
167 let path = PathBuf::from(trimmed);
168 Ok(dunce::canonicalize(&path).unwrap_or(path))
169}
170
171pub fn resolve_git_common_dir(cwd: &Path) -> Result<PathBuf, ChangedFilesError> {
185 let output = spawn_output(&mut git_command(
186 cwd,
187 &["rev-parse", "--path-format=absolute", "--git-common-dir"],
188 ))
189 .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
190
191 if !output.status.success() {
192 let stderr = String::from_utf8_lossy(&output.stderr);
193 return Err(if stderr.contains("not a git repository") {
194 ChangedFilesError::NotARepository
195 } else {
196 ChangedFilesError::GitFailed(stderr.trim().to_owned())
197 });
198 }
199
200 let raw = String::from_utf8_lossy(&output.stdout);
201 let trimmed = raw.trim();
202 if trimmed.is_empty() {
203 return Err(ChangedFilesError::GitFailed(
204 "git rev-parse --git-common-dir returned empty output".to_owned(),
205 ));
206 }
207
208 let path = PathBuf::from(trimmed);
209 Ok(dunce::canonicalize(&path).unwrap_or(path))
210}
211
212fn collect_git_paths(
213 cwd: &Path,
214 toplevel: &Path,
215 args: &[&str],
216) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
217 let output = spawn_output(&mut git_command(cwd, args))
218 .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
219
220 if !output.status.success() {
221 let stderr = String::from_utf8_lossy(&output.stderr);
222 return Err(if stderr.contains("not a git repository") {
223 ChangedFilesError::NotARepository
224 } else {
225 ChangedFilesError::GitFailed(stderr.trim().to_owned())
226 });
227 }
228
229 #[cfg(windows)]
230 let normalise_segment = |line: &str| line.replace('/', "\\");
231 #[cfg(not(windows))]
232 let normalise_segment = |line: &str| line.to_owned();
233
234 let files: FxHashSet<PathBuf> = String::from_utf8_lossy(&output.stdout)
235 .lines()
236 .filter(|line| !line.is_empty())
237 .map(|line| toplevel.join(normalise_segment(line)))
238 .collect();
239
240 Ok(files)
241}
242
243fn git_command(cwd: &Path, args: &[&str]) -> std::process::Command {
244 let mut command = crate::spawn::git();
245 command.args(args).current_dir(cwd);
246 command
247}
248
249pub fn try_get_changed_files(
267 root: &Path,
268 git_ref: &str,
269) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
270 validate_git_ref(git_ref).map_err(ChangedFilesError::InvalidRef)?;
271 let toplevel = resolve_git_toplevel(root)?;
272 try_get_changed_files_with_toplevel(root, &toplevel, git_ref)
273}
274
275pub fn try_get_changed_files_with_toplevel(
283 cwd: &Path,
284 toplevel: &Path,
285 git_ref: &str,
286) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
287 validate_git_ref(git_ref).map_err(ChangedFilesError::InvalidRef)?;
288
289 let mut files = collect_git_paths(
290 cwd,
291 toplevel,
292 &[
293 "diff",
294 "--name-only",
295 "--end-of-options",
296 &format!("{git_ref}...HEAD"),
297 ],
298 )?;
299 files.extend(collect_git_paths(
300 cwd,
301 toplevel,
302 &["diff", "--name-only", "HEAD"],
303 )?);
304 files.extend(collect_git_paths(
305 cwd,
306 toplevel,
307 &["ls-files", "--full-name", "--others", "--exclude-standard"],
308 )?);
309 Ok(files)
310}
311
312pub fn try_get_changed_diff(root: &Path, git_ref: &str) -> Result<String, ChangedFilesError> {
328 validate_git_ref(git_ref).map_err(ChangedFilesError::InvalidRef)?;
329 let output = spawn_output(&mut git_command(
330 root,
331 &[
332 "diff",
333 "--relative",
334 "--unified=0",
335 "--end-of-options",
336 &format!("{git_ref}...HEAD"),
337 ],
338 ))
339 .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
340
341 if !output.status.success() {
342 let stderr = String::from_utf8_lossy(&output.stderr);
343 return Err(if stderr.contains("not a git repository") {
344 ChangedFilesError::NotARepository
345 } else {
346 ChangedFilesError::GitFailed(stderr.trim().to_owned())
347 });
348 }
349
350 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
351}
352
353#[expect(
357 clippy::print_stderr,
358 reason = "intentional user-facing warning for the CLI's --changed-since fallback path; LSP callers use try_get_changed_files instead"
359)]
360pub fn get_changed_files(root: &Path, git_ref: &str) -> Option<FxHashSet<PathBuf>> {
361 match try_get_changed_files(root, git_ref) {
362 Ok(files) => Some(files),
363 Err(ChangedFilesError::InvalidRef(e)) => {
364 eprintln!("Warning: --changed-since ignored: invalid git ref: {e}");
365 None
366 }
367 Err(ChangedFilesError::GitMissing(e)) => {
368 eprintln!("Warning: --changed-since ignored: failed to run git: {e}");
369 None
370 }
371 Err(ChangedFilesError::NotARepository) => {
372 eprintln!("Warning: --changed-since ignored: not a git repository");
373 None
374 }
375 Err(ChangedFilesError::GitFailed(stderr)) => {
376 eprintln!("Warning: --changed-since failed for ref '{git_ref}': {stderr}");
377 None
378 }
379 }
380}
381
382#[expect(
395 clippy::implicit_hasher,
396 reason = "fallow standardizes on FxHashSet across the workspace"
397)]
398pub fn filter_results_by_changed_files(
399 results: &mut AnalysisResults,
400 changed_files: &FxHashSet<PathBuf>,
401) {
402 let AnalysisResults {
403 unused_files,
404 unused_exports,
405 unused_types,
406 private_type_leaks,
407 unused_dependencies: _unused_dependencies,
410 unused_dev_dependencies: _unused_dev_dependencies,
411 unused_optional_dependencies: _unused_optional_dependencies,
412 unused_enum_members,
413 unused_class_members,
414 unused_store_members,
415 unresolved_imports,
416 unlisted_dependencies,
417 duplicate_exports,
418 type_only_dependencies: _type_only_dependencies,
421 test_only_dependencies: _test_only_dependencies,
422 circular_dependencies,
423 re_export_cycles,
424 boundary_violations,
425 boundary_coverage_violations,
426 boundary_call_violations,
427 policy_violations,
428 stale_suppressions,
429 unused_catalog_entries: _unused_catalog_entries,
432 empty_catalog_groups,
433 unresolved_catalog_references,
434 unused_dependency_overrides,
435 misconfigured_dependency_overrides,
436 invalid_client_exports,
437 mixed_client_server_barrels,
438 misplaced_directives,
439 unprovided_injects,
440 unrendered_components,
441 route_collisions,
442 dynamic_segment_name_conflicts,
443 unused_component_props,
444 unused_component_emits,
445 unused_server_actions,
446 unused_load_data_keys,
447 unused_load_data_keys_global_abstain: _unused_load_data_keys_global_abstain,
449 prop_drilling_chains,
450 thin_wrappers,
451 duplicate_prop_shapes,
452 suppression_count: _suppression_count,
454 active_suppressions: _active_suppressions,
455 feature_flags: _feature_flags,
456 security_findings,
457 security_unresolved_edge_files: _security_unresolved_edge_files,
458 security_unresolved_callee_sites: _security_unresolved_callee_sites,
459 security_unresolved_callee_diagnostics,
460 export_usages: _export_usages,
463 entry_point_summary: _entry_point_summary,
464 render_fan_in: _render_fan_in,
468 } = &mut *results;
469
470 let cf = normalize_changed_files_set(changed_files);
471 unused_files.retain(|f| contains_normalized(&cf, &f.file.path));
472 unused_exports.retain(|e| contains_normalized(&cf, &e.export.path));
473 unused_types.retain(|e| contains_normalized(&cf, &e.export.path));
474 private_type_leaks.retain(|e| contains_normalized(&cf, &e.leak.path));
475 unused_enum_members.retain(|m| contains_normalized(&cf, &m.member.path));
476 unused_class_members.retain(|m| contains_normalized(&cf, &m.member.path));
477 unused_store_members.retain(|m| contains_normalized(&cf, &m.member.path));
478 unresolved_imports.retain(|i| contains_normalized(&cf, &i.import.path));
479
480 unlisted_dependencies.retain(|d| {
481 d.dep
482 .imported_from
483 .iter()
484 .any(|s| contains_normalized(&cf, &s.path))
485 });
486
487 for dup in &mut *duplicate_exports {
488 dup.export
489 .locations
490 .retain(|loc| contains_normalized(&cf, &loc.path));
491 }
492 duplicate_exports.retain(|d| d.export.locations.len() >= 2);
493
494 circular_dependencies.retain(|c| c.cycle.files.iter().any(|f| contains_normalized(&cf, f)));
495
496 re_export_cycles.retain(|c| c.cycle.files.iter().any(|f| contains_normalized(&cf, f)));
497
498 boundary_violations.retain(|v| contains_normalized(&cf, &v.violation.from_path));
499 boundary_coverage_violations.retain(|v| contains_normalized(&cf, &v.violation.path));
500 boundary_call_violations.retain(|v| contains_normalized(&cf, &v.violation.path));
501 policy_violations.retain(|v| contains_normalized(&cf, &v.violation.path));
502
503 stale_suppressions.retain(|s| contains_normalized(&cf, &s.path));
504
505 security_findings.retain(|f| {
506 contains_normalized(&cf, &f.path)
507 || f.trace
508 .iter()
509 .any(|hop| contains_normalized(&cf, &hop.path))
510 || f.reachability.as_ref().is_some_and(|reachability| {
511 reachability
512 .untrusted_source_trace
513 .iter()
514 .any(|hop| contains_normalized(&cf, &hop.path))
515 })
516 });
517 security_unresolved_callee_diagnostics.retain(|d| contains_normalized(&cf, &d.path));
518
519 unresolved_catalog_references.retain(|r| contains_normalized(&cf, &r.reference.path));
520 empty_catalog_groups.retain(|g| normalized_set_contains_path(&cf, &g.group.path));
521
522 unused_dependency_overrides.retain(|o| contains_normalized(&cf, &o.entry.path));
523 misconfigured_dependency_overrides.retain(|o| contains_normalized(&cf, &o.entry.path));
524
525 invalid_client_exports.retain(|e| contains_normalized(&cf, &e.export.path));
526 mixed_client_server_barrels.retain(|b| contains_normalized(&cf, &b.barrel.path));
527 misplaced_directives.retain(|d| contains_normalized(&cf, &d.directive_site.path));
528 unprovided_injects.retain(|i| contains_normalized(&cf, &i.inject.path));
529 unrendered_components.retain(|c| contains_normalized(&cf, &c.component.path));
530 route_collisions.retain(|c| contains_normalized(&cf, &c.collision.path));
531 dynamic_segment_name_conflicts.retain(|c| contains_normalized(&cf, &c.conflict.path));
532 unused_component_props.retain(|p| contains_normalized(&cf, &p.prop.path));
533 unused_component_emits.retain(|e| contains_normalized(&cf, &e.emit.path));
534 unused_server_actions.retain(|a| contains_normalized(&cf, &a.action.path));
535 unused_load_data_keys.retain(|k| contains_normalized(&cf, &k.key.path));
536 prop_drilling_chains.retain(|c| {
538 c.chain
539 .hops
540 .first()
541 .is_some_and(|h| contains_normalized(&cf, &h.file))
542 });
543 thin_wrappers.retain(|w| contains_normalized(&cf, &w.wrapper.file));
545 duplicate_prop_shapes.retain(|d| contains_normalized(&cf, &d.shape.file));
547}
548
549fn normalize_changed_files_set(changed_files: &FxHashSet<PathBuf>) -> FxHashSet<PathBuf> {
562 changed_files
563 .iter()
564 .map(|p| dunce::simplified(p).to_path_buf())
565 .collect()
566}
567
568fn contains_normalized(normalized: &FxHashSet<PathBuf>, path: &Path) -> bool {
569 normalized.contains(dunce::simplified(path))
570}
571
572fn normalized_set_contains_path(normalized: &FxHashSet<PathBuf>, path: &Path) -> bool {
573 contains_normalized(normalized, path)
574 || (path.is_relative() && normalized.iter().any(|changed| changed.ends_with(path)))
575}
576
577fn recompute_duplication_stats(report: &DuplicationReport) -> DuplicationStats {
583 let mut files_with_clones: FxHashSet<&Path> = FxHashSet::default();
584 let mut file_dup_lines: FxHashMap<&Path, FxHashSet<usize>> = FxHashMap::default();
585 let mut duplicated_tokens = 0_usize;
586 let mut clone_instances = 0_usize;
587
588 for group in &report.clone_groups {
589 for instance in &group.instances {
590 files_with_clones.insert(&instance.file);
591 clone_instances += 1;
592 let lines = file_dup_lines.entry(&instance.file).or_default();
593 for line in instance.start_line..=instance.end_line {
594 lines.insert(line);
595 }
596 }
597 duplicated_tokens += group.token_count * group.instances.len();
598 }
599
600 let duplicated_lines: usize = file_dup_lines.values().map(FxHashSet::len).sum();
601
602 DuplicationStats {
603 total_files: report.stats.total_files,
604 files_with_clones: files_with_clones.len(),
605 total_lines: report.stats.total_lines,
606 duplicated_lines,
607 total_tokens: report.stats.total_tokens,
608 duplicated_tokens,
609 clone_groups: report.clone_groups.len(),
610 clone_instances,
611 #[expect(
612 clippy::cast_precision_loss,
613 reason = "stat percentages are display-only; precision loss at usize::MAX line counts is acceptable"
614 )]
615 duplication_percentage: if report.stats.total_lines > 0 {
616 (duplicated_lines as f64 / report.stats.total_lines as f64) * 100.0
617 } else {
618 0.0
619 },
620 clone_groups_below_min_occurrences: report.stats.clone_groups_below_min_occurrences,
621 }
622}
623
624#[expect(
629 clippy::implicit_hasher,
630 reason = "fallow standardizes on FxHashSet across the workspace"
631)]
632pub fn filter_duplication_by_changed_files(
633 report: &mut DuplicationReport,
634 changed_files: &FxHashSet<PathBuf>,
635 root: &Path,
636) {
637 let cf = normalize_changed_files_set(changed_files);
638 report.clone_groups.retain(|g| {
639 g.instances
640 .iter()
641 .any(|i| contains_normalized(&cf, &i.file))
642 });
643 report.clone_families = families::group_into_families(&report.clone_groups, root);
644 report.mirrored_directories =
645 families::detect_mirrored_directories(&report.clone_families, root);
646 report.stats = recompute_duplication_stats(report);
647}
648
649#[cfg(test)]
650mod tests {
651 use super::*;
652 use crate::duplicates::{CloneGroup, CloneInstance};
653 use crate::results::{
654 BoundaryViolation, CircularDependency, EmptyCatalogGroup, SecurityFinding,
655 SecurityFindingKind, SecurityUnresolvedCalleeDiagnostic, TraceHop, TraceHopRole,
656 UnusedExport, UnusedFile,
657 };
658 use fallow_types::extract::{SkippedSecurityCalleeExpressionKind, SkippedSecurityCalleeReason};
659 use fallow_types::output_dead_code::{
660 BoundaryViolationFinding, CircularDependencyFinding, EmptyCatalogGroupFinding,
661 UnusedExportFinding, UnusedFileFinding,
662 };
663 use fallow_types::results::{SecurityReachability, SecuritySeverity};
664
665 #[test]
666 fn changed_files_error_describe_variants() {
667 assert!(
668 ChangedFilesError::InvalidRef("bad".to_owned())
669 .describe()
670 .contains("invalid git ref")
671 );
672 assert!(
673 ChangedFilesError::GitMissing("oops".to_owned())
674 .describe()
675 .contains("oops")
676 );
677 assert_eq!(
678 ChangedFilesError::NotARepository.describe(),
679 "not a git repository"
680 );
681 assert!(
682 ChangedFilesError::GitFailed("bad ref".to_owned())
683 .describe()
684 .contains("bad ref")
685 );
686 }
687
688 #[test]
689 fn augment_git_failed_appends_shallow_clone_hint_for_unknown_revision() {
690 let stderr = "fatal: ambiguous argument 'fallow-baseline...HEAD': unknown revision or path not in the working tree.";
691 let described = ChangedFilesError::GitFailed(stderr.to_owned()).describe();
692 assert!(described.contains(stderr), "original stderr preserved");
693 assert!(
694 described.contains("shallow clone"),
695 "hint surfaced: {described}"
696 );
697 assert!(
698 described.contains("fetch-depth: 0") || described.contains("git fetch --unshallow"),
699 "hint actionable: {described}"
700 );
701 }
702
703 #[test]
704 fn augment_git_failed_passthrough_for_other_errors() {
705 let stderr = "fatal: refusing to merge unrelated histories";
706 let described = ChangedFilesError::GitFailed(stderr.to_owned()).describe();
707 assert_eq!(described, stderr);
708 }
709
710 #[test]
711 fn validate_git_ref_rejects_leading_dash() {
712 assert!(validate_git_ref("--upload-pack=evil").is_err());
713 assert!(validate_git_ref("-flag").is_err());
714 }
715
716 #[test]
717 fn validate_git_ref_accepts_baseline_tag() {
718 assert_eq!(
719 validate_git_ref("fallow-baseline").unwrap(),
720 "fallow-baseline"
721 );
722 }
723
724 #[test]
725 fn changed_files_filter_scopes_unresolved_callee_diagnostics() {
726 let mut results = AnalysisResults::default();
727 results
728 .security_unresolved_callee_diagnostics
729 .push(SecurityUnresolvedCalleeDiagnostic {
730 path: PathBuf::from("/repo/src/changed.ts"),
731 line: 4,
732 col: 0,
733 reason: SkippedSecurityCalleeReason::DynamicDispatch,
734 expression_kind: SkippedSecurityCalleeExpressionKind::Other,
735 });
736 results
737 .security_unresolved_callee_diagnostics
738 .push(SecurityUnresolvedCalleeDiagnostic {
739 path: PathBuf::from("/repo/src/unchanged.ts"),
740 line: 4,
741 col: 0,
742 reason: SkippedSecurityCalleeReason::ComputedMember,
743 expression_kind: SkippedSecurityCalleeExpressionKind::ComputedMemberExpression,
744 });
745
746 let mut changed: FxHashSet<PathBuf> = FxHashSet::default();
747 changed.insert(PathBuf::from("/repo/src/changed.ts"));
748
749 filter_results_by_changed_files(&mut results, &changed);
750
751 assert_eq!(results.security_unresolved_callee_diagnostics.len(), 1);
752 assert_eq!(
753 results.security_unresolved_callee_diagnostics[0].path,
754 PathBuf::from("/repo/src/changed.ts")
755 );
756 }
757
758 #[test]
759 fn try_get_changed_files_rejects_invalid_ref() {
760 let err = try_get_changed_files(Path::new("/"), "--evil")
761 .expect_err("leading-dash ref must be rejected");
762 assert!(matches!(err, ChangedFilesError::InvalidRef(_)));
763 assert!(err.describe().contains("cannot start with"));
764 }
765
766 #[test]
767 fn validate_git_ref_rejects_option_like_ref() {
768 assert!(validate_git_ref("--output=/tmp/fallow-proof").is_err());
769 }
770
771 #[test]
772 fn validate_git_ref_allows_reflog_relative_date() {
773 assert!(validate_git_ref("HEAD@{1 week ago}").is_ok());
774 }
775
776 #[test]
777 fn try_get_changed_files_rejects_option_like_ref_before_git() {
778 let root = tempfile::tempdir().expect("create temp dir");
779 let proof_path = root.path().join("proof");
780
781 let result = try_get_changed_files(
782 root.path(),
783 &format!("--output={}", proof_path.to_string_lossy()),
784 );
785
786 assert!(matches!(result, Err(ChangedFilesError::InvalidRef(_))));
787 assert!(
788 !proof_path.exists(),
789 "invalid changedSince ref must not be passed through to git as an option"
790 );
791 }
792
793 #[test]
794 fn git_command_clears_parent_git_environment() {
795 let command = git_command(Path::new("."), &["status", "--short"]);
796 let overrides: Vec<_> = command.get_envs().collect();
797
798 for var in crate::git_env::AMBIENT_GIT_ENV_VARS {
799 assert!(
800 overrides
801 .iter()
802 .any(|(key, value)| key.to_str() == Some(*var) && value.is_none()),
803 "git helper must clear inherited {var}",
804 );
805 }
806 }
807
808 #[test]
809 fn filter_results_keeps_only_changed_files() {
810 let mut results = AnalysisResults::default();
811 results
812 .unused_files
813 .push(UnusedFileFinding::with_actions(UnusedFile {
814 path: "/a.ts".into(),
815 }));
816 results
817 .unused_files
818 .push(UnusedFileFinding::with_actions(UnusedFile {
819 path: "/b.ts".into(),
820 }));
821 results
822 .unused_exports
823 .push(UnusedExportFinding::with_actions(UnusedExport {
824 path: "/a.ts".into(),
825 export_name: "foo".into(),
826 is_type_only: false,
827 line: 1,
828 col: 0,
829 span_start: 0,
830 is_re_export: false,
831 }));
832
833 let mut changed: FxHashSet<PathBuf> = FxHashSet::default();
834 changed.insert("/a.ts".into());
835
836 filter_results_by_changed_files(&mut results, &changed);
837
838 assert_eq!(results.unused_files.len(), 1);
839 assert_eq!(results.unused_files[0].file.path, PathBuf::from("/a.ts"));
840 assert_eq!(results.unused_exports.len(), 1);
841 }
842
843 #[test]
844 fn filter_results_preserves_dependency_level_issues() {
845 let mut results = AnalysisResults::default();
846 results.unused_dependencies.push(
847 fallow_types::output_dead_code::UnusedDependencyFinding::with_actions(
848 crate::results::UnusedDependency {
849 package_name: "lodash".into(),
850 location: crate::results::DependencyLocation::Dependencies,
851 path: "/pkg.json".into(),
852 line: 3,
853 used_in_workspaces: Vec::new(),
854 },
855 ),
856 );
857
858 let changed: FxHashSet<PathBuf> = FxHashSet::default();
859 filter_results_by_changed_files(&mut results, &changed);
860
861 assert_eq!(results.unused_dependencies.len(), 1);
862 }
863
864 #[test]
865 fn filter_results_keeps_circular_dep_when_any_file_changed() {
866 let mut results = AnalysisResults::default();
867 results
868 .circular_dependencies
869 .push(CircularDependencyFinding::with_actions(
870 CircularDependency {
871 files: vec!["/a.ts".into(), "/b.ts".into()],
872 length: 2,
873 line: 1,
874 col: 0,
875 edges: Vec::new(),
876 is_cross_package: false,
877 },
878 ));
879
880 let mut changed: FxHashSet<PathBuf> = FxHashSet::default();
881 changed.insert("/b.ts".into());
882
883 filter_results_by_changed_files(&mut results, &changed);
884 assert_eq!(results.circular_dependencies.len(), 1);
885 }
886
887 #[test]
888 fn filter_results_drops_circular_dep_when_no_file_changed() {
889 let mut results = AnalysisResults::default();
890 results
891 .circular_dependencies
892 .push(CircularDependencyFinding::with_actions(
893 CircularDependency {
894 files: vec!["/a.ts".into(), "/b.ts".into()],
895 length: 2,
896 line: 1,
897 col: 0,
898 edges: Vec::new(),
899 is_cross_package: false,
900 },
901 ));
902
903 let changed: FxHashSet<PathBuf> = FxHashSet::default();
904 filter_results_by_changed_files(&mut results, &changed);
905 assert!(results.circular_dependencies.is_empty());
906 }
907
908 #[test]
909 fn filter_results_drops_boundary_violation_when_importer_unchanged() {
910 let mut results = AnalysisResults::default();
911 results
912 .boundary_violations
913 .push(BoundaryViolationFinding::with_actions(BoundaryViolation {
914 from_path: "/a.ts".into(),
915 to_path: "/b.ts".into(),
916 from_zone: "ui".into(),
917 to_zone: "data".into(),
918 import_specifier: "../data/db".into(),
919 line: 1,
920 col: 0,
921 }));
922
923 let mut changed: FxHashSet<PathBuf> = FxHashSet::default();
924 changed.insert("/b.ts".into());
925
926 filter_results_by_changed_files(&mut results, &changed);
927 assert!(results.boundary_violations.is_empty());
928 }
929
930 #[test]
931 fn filter_results_keeps_security_finding_when_trace_file_changed() {
932 let mut results = AnalysisResults::default();
933 results.security_findings.push(SecurityFinding {
934 finding_id: String::new(),
935 candidate: fallow_types::results::SecurityCandidate::default(),
936 taint_flow: None,
937 attack_surface: None,
938 kind: SecurityFindingKind::ClientServerLeak,
939 category: None,
940 cwe: None,
941 path: "/project/src/client.tsx".into(),
942 line: 2,
943 col: 0,
944 evidence: "candidate".into(),
945 source_backed: false,
946 source_read: None,
947 severity: SecuritySeverity::Low,
948 trace: vec![
949 TraceHop {
950 path: "/project/src/client.tsx".into(),
951 line: 2,
952 col: 0,
953 role: TraceHopRole::ClientBoundary,
954 },
955 TraceHop {
956 path: "/project/src/server.ts".into(),
957 line: 1,
958 col: 0,
959 role: TraceHopRole::SecretSource,
960 },
961 ],
962 actions: Vec::new(),
963 dead_code: None,
964 reachability: None,
965 runtime: None,
966 });
967
968 let mut changed: FxHashSet<PathBuf> = FxHashSet::default();
969 changed.insert("/project/src/server.ts".into());
970
971 filter_results_by_changed_files(&mut results, &changed);
972
973 assert_eq!(results.security_findings.len(), 1);
974 }
975
976 #[test]
977 fn filter_results_keeps_security_finding_when_untrusted_source_trace_file_changed() {
978 let mut results = AnalysisResults::default();
979 results.security_findings.push(SecurityFinding {
980 finding_id: String::new(),
981 candidate: fallow_types::results::SecurityCandidate::default(),
982 taint_flow: None,
983 attack_surface: None,
984 kind: SecurityFindingKind::TaintedSink,
985 category: Some("command-injection".into()),
986 cwe: Some(78),
987 path: "/project/src/runner.ts".into(),
988 line: 4,
989 col: 2,
990 evidence: "candidate".into(),
991 source_backed: false,
992 source_read: None,
993 severity: SecuritySeverity::Low,
994 trace: Vec::new(),
995 actions: Vec::new(),
996 dead_code: None,
997 reachability: Some(SecurityReachability {
998 reachable_from_entry: false,
999 reachable_from_untrusted_source: true,
1000 taint_confidence: Some(fallow_types::results::TaintConfidence::ModuleLevel),
1001 untrusted_source_hop_count: Some(1),
1002 untrusted_source_trace: vec![
1003 TraceHop {
1004 path: "/project/src/route.ts".into(),
1005 line: 1,
1006 col: 0,
1007 role: TraceHopRole::UntrustedSource,
1008 },
1009 TraceHop {
1010 path: "/project/src/runner.ts".into(),
1011 line: 4,
1012 col: 2,
1013 role: TraceHopRole::Sink,
1014 },
1015 ],
1016 blast_radius: 0,
1017 crosses_boundary: false,
1018 }),
1019 runtime: None,
1020 });
1021
1022 let mut changed: FxHashSet<PathBuf> = FxHashSet::default();
1023 changed.insert("/project/src/route.ts".into());
1024
1025 filter_results_by_changed_files(&mut results, &changed);
1026
1027 assert_eq!(results.security_findings.len(), 1);
1028 }
1029
1030 #[test]
1031 fn filter_results_keeps_relative_empty_catalog_group_when_manifest_changed() {
1032 let mut results = AnalysisResults::default();
1033 results
1034 .empty_catalog_groups
1035 .push(EmptyCatalogGroupFinding::with_actions(EmptyCatalogGroup {
1036 catalog_name: "legacy".into(),
1037 path: PathBuf::from("pnpm-workspace.yaml"),
1038 line: 4,
1039 }));
1040
1041 let mut changed: FxHashSet<PathBuf> = FxHashSet::default();
1042 changed.insert(PathBuf::from("/repo/pnpm-workspace.yaml"));
1043
1044 filter_results_by_changed_files(&mut results, &changed);
1045
1046 assert_eq!(results.empty_catalog_groups.len(), 1);
1047 assert_eq!(results.empty_catalog_groups[0].group.catalog_name, "legacy");
1048 }
1049
1050 #[test]
1051 fn filter_duplication_keeps_groups_with_at_least_one_changed_instance() {
1052 let mut report = DuplicationReport {
1053 clone_groups: vec![CloneGroup {
1054 instances: vec![
1055 CloneInstance {
1056 file: "/a.ts".into(),
1057 start_line: 1,
1058 end_line: 5,
1059 start_col: 0,
1060 end_col: 10,
1061 fragment: "code".into(),
1062 },
1063 CloneInstance {
1064 file: "/b.ts".into(),
1065 start_line: 1,
1066 end_line: 5,
1067 start_col: 0,
1068 end_col: 10,
1069 fragment: "code".into(),
1070 },
1071 ],
1072 token_count: 20,
1073 line_count: 5,
1074 }],
1075 clone_families: vec![],
1076 mirrored_directories: vec![],
1077 stats: DuplicationStats {
1078 total_files: 2,
1079 files_with_clones: 2,
1080 total_lines: 100,
1081 duplicated_lines: 10,
1082 total_tokens: 200,
1083 duplicated_tokens: 40,
1084 clone_groups: 1,
1085 clone_instances: 2,
1086 duplication_percentage: 10.0,
1087 clone_groups_below_min_occurrences: 0,
1088 },
1089 };
1090
1091 let mut changed: FxHashSet<PathBuf> = FxHashSet::default();
1092 changed.insert("/a.ts".into());
1093
1094 filter_duplication_by_changed_files(&mut report, &changed, Path::new(""));
1095 assert_eq!(report.clone_groups.len(), 1);
1096 assert_eq!(report.stats.clone_groups, 1);
1097 assert_eq!(report.stats.clone_instances, 2);
1098 }
1099
1100 #[cfg(windows)]
1108 #[test]
1109 fn filter_duplication_normalises_verbatim_prefix_mismatch() {
1110 let mut report = DuplicationReport {
1111 clone_groups: vec![CloneGroup {
1112 instances: vec![
1113 CloneInstance {
1114 file: PathBuf::from(r"\\?\C:\repo\src\changed.ts"),
1115 start_line: 1,
1116 end_line: 5,
1117 start_col: 0,
1118 end_col: 10,
1119 fragment: "code".into(),
1120 },
1121 CloneInstance {
1122 file: PathBuf::from(r"\\?\C:\repo\src\focused-copy.ts"),
1123 start_line: 1,
1124 end_line: 5,
1125 start_col: 0,
1126 end_col: 10,
1127 fragment: "code".into(),
1128 },
1129 ],
1130 token_count: 20,
1131 line_count: 5,
1132 }],
1133 clone_families: vec![],
1134 mirrored_directories: vec![],
1135 stats: DuplicationStats {
1136 total_files: 2,
1137 files_with_clones: 2,
1138 total_lines: 100,
1139 duplicated_lines: 10,
1140 total_tokens: 200,
1141 duplicated_tokens: 40,
1142 clone_groups: 1,
1143 clone_instances: 2,
1144 duplication_percentage: 10.0,
1145 clone_groups_below_min_occurrences: 0,
1146 },
1147 };
1148
1149 let mut changed: FxHashSet<PathBuf> = FxHashSet::default();
1150 changed.insert(PathBuf::from(r"C:\repo\src\changed.ts"));
1151
1152 filter_duplication_by_changed_files(&mut report, &changed, Path::new(""));
1153 assert_eq!(
1154 report.clone_groups.len(),
1155 1,
1156 "verbatim instance path must match non-verbatim changed-file entry"
1157 );
1158 }
1159
1160 #[cfg(windows)]
1161 #[test]
1162 fn filter_results_normalises_verbatim_prefix_mismatch() {
1163 let mut results = AnalysisResults::default();
1164 results
1165 .unused_exports
1166 .push(UnusedExportFinding::with_actions(UnusedExport {
1167 path: PathBuf::from(r"\\?\C:\repo\src\a.ts"),
1168 export_name: "foo".into(),
1169 is_type_only: false,
1170 line: 1,
1171 col: 0,
1172 span_start: 0,
1173 is_re_export: false,
1174 }));
1175
1176 let mut changed: FxHashSet<PathBuf> = FxHashSet::default();
1177 changed.insert(PathBuf::from(r"C:\repo\src\a.ts"));
1178
1179 filter_results_by_changed_files(&mut results, &changed);
1180 assert_eq!(
1181 results.unused_exports.len(),
1182 1,
1183 "verbatim finding path must match non-verbatim changed-file entry"
1184 );
1185 }
1186
1187 fn init_repo(repo: &Path) -> PathBuf {
1199 run_git(repo, &["init", "--quiet", "--initial-branch=main"]);
1200 run_git(repo, &["config", "user.email", "test@example.com"]);
1201 run_git(repo, &["config", "user.name", "test"]);
1202 run_git(repo, &["config", "commit.gpgsign", "false"]);
1203 std::fs::write(repo.join("seed.txt"), "seed\n").unwrap();
1204 run_git(repo, &["add", "seed.txt"]);
1205 run_git(repo, &["commit", "--quiet", "-m", "initial"]);
1206 run_git(repo, &["tag", "fallow-baseline"]);
1207 dunce::canonicalize(repo).unwrap()
1208 }
1209
1210 fn run_git(cwd: &Path, args: &[&str]) {
1211 let output = std::process::Command::new("git")
1212 .args(args)
1213 .current_dir(cwd)
1214 .output()
1215 .expect("git available");
1216 assert!(
1217 output.status.success(),
1218 "git {args:?} failed: {}",
1219 String::from_utf8_lossy(&output.stderr)
1220 );
1221 }
1222
1223 #[test]
1226 fn try_get_changed_files_workspace_at_repo_root() {
1227 let tmp = tempfile::tempdir().unwrap();
1228 let repo = init_repo(tmp.path());
1229 std::fs::create_dir_all(repo.join("src")).unwrap();
1230 std::fs::write(repo.join("src/new.ts"), "export const x = 1;\n").unwrap();
1231
1232 let changed = try_get_changed_files(&repo, "fallow-baseline").unwrap();
1233
1234 let expected = repo.join("src/new.ts");
1235 assert!(
1236 changed.contains(&expected),
1237 "changed set should contain {expected:?}; actual: {changed:?}"
1238 );
1239 }
1240
1241 #[test]
1249 fn try_get_changed_files_workspace_in_subdirectory() {
1250 let tmp = tempfile::tempdir().unwrap();
1251 let repo = init_repo(tmp.path());
1252 let frontend = repo.join("frontend");
1253 std::fs::create_dir_all(frontend.join("src")).unwrap();
1254 std::fs::write(frontend.join("src/new.ts"), "export const x = 1;\n").unwrap();
1255
1256 let changed = try_get_changed_files(&frontend, "fallow-baseline").unwrap();
1257
1258 let expected = repo.join("frontend/src/new.ts");
1259 assert!(
1260 changed.contains(&expected),
1261 "changed set should contain canonical {expected:?}; actual: {changed:?}"
1262 );
1263 let bogus = frontend.join("frontend/src/new.ts");
1264 assert!(
1265 !changed.contains(&bogus),
1266 "changed set must not contain double-frontend path {bogus:?}"
1267 );
1268 }
1269
1270 #[test]
1285 fn try_get_changed_files_includes_committed_sibling_changes() {
1286 let tmp = tempfile::tempdir().unwrap();
1287 let repo = init_repo(tmp.path());
1288 let backend = repo.join("backend");
1289 std::fs::create_dir_all(&backend).unwrap();
1290 std::fs::write(backend.join("server.py"), "print('hi')\n").unwrap();
1291 run_git(&repo, &["add", "."]);
1292 run_git(&repo, &["commit", "--quiet", "-m", "add backend"]);
1293
1294 let frontend = repo.join("frontend");
1295 std::fs::create_dir_all(&frontend).unwrap();
1296
1297 let changed = try_get_changed_files(&frontend, "fallow-baseline").unwrap();
1298
1299 let expected = repo.join("backend/server.py");
1300 assert!(
1301 changed.contains(&expected),
1302 "committed sibling backend/server.py should be in the set: {changed:?}"
1303 );
1304 }
1305
1306 #[test]
1310 fn try_get_changed_files_includes_modified_tracked_file() {
1311 let tmp = tempfile::tempdir().unwrap();
1312 let repo = init_repo(tmp.path());
1313 let frontend = repo.join("frontend");
1314 std::fs::create_dir_all(frontend.join("src")).unwrap();
1315 std::fs::write(frontend.join("src/old.ts"), "export const x = 1;\n").unwrap();
1316 run_git(&repo, &["add", "."]);
1317 run_git(&repo, &["commit", "--quiet", "-m", "add old"]);
1318 run_git(&repo, &["tag", "fallow-baseline-v2"]);
1319 std::fs::write(frontend.join("src/old.ts"), "export const x = 2;\n").unwrap();
1320
1321 let changed = try_get_changed_files(&frontend, "fallow-baseline-v2").unwrap();
1322
1323 let expected = repo.join("frontend/src/old.ts");
1324 assert!(
1325 changed.contains(&expected),
1326 "modified tracked file {expected:?} missing from set: {changed:?}"
1327 );
1328 }
1329
1330 #[test]
1336 fn resolve_git_toplevel_returns_canonical_path() {
1337 let tmp = tempfile::tempdir().unwrap();
1338 let repo = init_repo(tmp.path());
1339 let frontend = repo.join("frontend");
1340 std::fs::create_dir_all(&frontend).unwrap();
1341
1342 let toplevel = resolve_git_toplevel(&frontend).unwrap();
1343 assert_eq!(toplevel, repo, "toplevel should equal canonical repo root");
1344 assert_eq!(
1345 toplevel,
1346 dunce::canonicalize(&toplevel).unwrap(),
1347 "resolved toplevel should already be canonical"
1348 );
1349 }
1350
1351 #[test]
1355 fn resolve_git_toplevel_not_a_repository() {
1356 let tmp = tempfile::tempdir().unwrap();
1357 let result = resolve_git_toplevel(tmp.path());
1358 assert!(
1359 matches!(result, Err(ChangedFilesError::NotARepository)),
1360 "expected NotARepository, got {result:?}"
1361 );
1362 }
1363
1364 #[test]
1369 fn resolve_git_common_dir_collapses_worktrees() {
1370 let tmp = tempfile::tempdir().unwrap();
1371 let repo = init_repo(tmp.path());
1372 let linked = tmp.path().join("linked-worktree");
1373 run_git(
1374 &repo,
1375 &[
1376 "worktree",
1377 "add",
1378 "--quiet",
1379 linked.to_str().unwrap(),
1380 "-b",
1381 "feat",
1382 ],
1383 );
1384
1385 let main_common = resolve_git_common_dir(&repo).unwrap();
1386 let linked_common = resolve_git_common_dir(&linked).unwrap();
1387 assert_eq!(
1388 main_common, linked_common,
1389 "worktrees of one repo must share a common dir"
1390 );
1391
1392 let main_top = resolve_git_toplevel(&repo).unwrap();
1394 let linked_top = resolve_git_toplevel(&linked).unwrap();
1395 assert_ne!(
1396 main_top, linked_top,
1397 "the two worktrees should have distinct toplevels"
1398 );
1399 }
1400
1401 #[test]
1404 fn resolve_git_common_dir_not_a_repository() {
1405 let tmp = tempfile::tempdir().unwrap();
1406 let result = resolve_git_common_dir(tmp.path());
1407 assert!(
1408 matches!(result, Err(ChangedFilesError::NotARepository)),
1409 "expected NotARepository, got {result:?}"
1410 );
1411 }
1412
1413 #[test]
1416 fn try_get_changed_files_not_a_repository() {
1417 let tmp = tempfile::tempdir().unwrap();
1418 let result = try_get_changed_files(tmp.path(), "main");
1419 assert!(matches!(result, Err(ChangedFilesError::NotARepository)));
1420 }
1421
1422 #[test]
1423 fn filter_duplication_drops_groups_with_no_changed_instance() {
1424 let mut report = DuplicationReport {
1425 clone_groups: vec![CloneGroup {
1426 instances: vec![CloneInstance {
1427 file: "/a.ts".into(),
1428 start_line: 1,
1429 end_line: 5,
1430 start_col: 0,
1431 end_col: 10,
1432 fragment: "code".into(),
1433 }],
1434 token_count: 20,
1435 line_count: 5,
1436 }],
1437 clone_families: vec![],
1438 mirrored_directories: vec![],
1439 stats: DuplicationStats {
1440 total_files: 1,
1441 files_with_clones: 1,
1442 total_lines: 100,
1443 duplicated_lines: 5,
1444 total_tokens: 100,
1445 duplicated_tokens: 20,
1446 clone_groups: 1,
1447 clone_instances: 1,
1448 duplication_percentage: 5.0,
1449 clone_groups_below_min_occurrences: 0,
1450 },
1451 };
1452
1453 let changed: FxHashSet<PathBuf> = FxHashSet::default();
1454 filter_duplication_by_changed_files(&mut report, &changed, Path::new(""));
1455 assert!(report.clone_groups.is_empty());
1456 assert_eq!(report.stats.clone_groups, 0);
1457 assert_eq!(report.stats.clone_instances, 0);
1458 assert!((report.stats.duplication_percentage - 0.0).abs() < f64::EPSILON);
1459 }
1460}