1use std::path::{Path, PathBuf};
4use std::process::{Command, Output, Stdio};
5use std::sync::OnceLock;
6
7use fallow_types::{
8 output_dead_code::{
9 CircularDependencyFinding, DuplicateExportFinding, DuplicatePropShapeFinding,
10 PackageCycleFinding, PropDrillingChainFinding, ReExportCycleFinding,
11 UnlistedDependencyFinding,
12 },
13 results::{AnalysisResults, SecurityFinding},
14};
15use rustc_hash::FxHashSet;
16
17use crate::duplicates::{self, DuplicationReport};
18
19pub use crate::git_env::{AMBIENT_GIT_ENV_VARS, clear_ambient_git_env};
20
21pub type ChangedFilesSpawnHook = fn(&mut std::process::Command) -> std::io::Result<Output>;
24
25static SPAWN_HOOK: OnceLock<ChangedFilesSpawnHook> = OnceLock::new();
26
27#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum ChangedFilesError {
30 InvalidRef(String),
32 GitMissing(String),
34 NotARepository,
36 GitFailed(String),
38}
39
40impl ChangedFilesError {
41 #[must_use]
43 pub fn describe(&self) -> String {
44 match self {
45 Self::InvalidRef(err) => format!("invalid git ref: {err}"),
46 Self::GitMissing(err) => format!("failed to run git: {err}"),
47 Self::NotARepository => "not a git repository".to_owned(),
48 Self::GitFailed(stderr) => augment_git_failed(stderr),
49 }
50 }
51
52 #[must_use]
58 pub const fn reason(&self) -> &'static str {
59 match self {
60 Self::InvalidRef(_) => "invalid-ref",
61 Self::GitMissing(_) => "git-missing",
62 Self::NotARepository => "not-a-repository",
63 Self::GitFailed(_) => "git-failed",
64 }
65 }
66
67 #[must_use]
78 pub fn changed_since_message(&self, git_ref: &str) -> String {
79 let cause = self
80 .describe()
81 .split_whitespace()
82 .collect::<Vec<_>>()
83 .join(" ");
84 format!(
85 "--changed-since '{git_ref}' was ignored because {cause}, so this report covers \
86 the whole project instead of the changed files. {}",
87 self.changed_since_remedy()
88 )
89 }
90
91 const fn changed_since_remedy(&self) -> &'static str {
93 match self {
94 Self::InvalidRef(_) => {
95 "Pass a ref git can resolve, such as a branch name or a commit sha."
96 }
97 Self::GitMissing(_) => {
98 "Install git and make it available on PATH, or drop --changed-since."
99 }
100 Self::NotARepository => {
101 "Run fallow from inside the repository, or drop --changed-since."
102 }
103 Self::GitFailed(_) => {
104 "Verify the ref exists in this repository, and check out with full history."
105 }
106 }
107 }
108}
109
110fn augment_git_failed(stderr: &str) -> String {
111 let lower = stderr.to_ascii_lowercase();
112 if lower.contains("not a valid object name")
113 || lower.contains("unknown revision")
114 || lower.contains("ambiguous argument")
115 {
116 format!(
117 "{stderr} (shallow clone? try `git fetch --unshallow`, or set `fetch-depth: 0` on actions/checkout / `GIT_DEPTH: 0` in GitLab CI)"
118 )
119 } else {
120 stderr.to_owned()
121 }
122}
123
124pub fn set_spawn_hook(hook: ChangedFilesSpawnHook) {
126 let _ = SPAWN_HOOK.set(hook);
127}
128
129pub(crate) fn validate_git_ref(s: &str) -> Result<&str, String> {
131 if s.is_empty() {
132 return Err("git ref cannot be empty".to_string());
133 }
134 if s.starts_with('-') {
135 return Err("git ref cannot start with '-'".to_string());
136 }
137 let mut in_braces = false;
138 for c in s.chars() {
139 match c {
140 '{' => in_braces = true,
141 '}' => in_braces = false,
142 ':' | ' ' if in_braces => {}
143 c if c.is_ascii_alphanumeric()
144 || matches!(c, '.' | '_' | '-' | '/' | '~' | '^' | '@' | '{' | '}') => {}
145 _ => return Err(format!("git ref contains disallowed character: '{c}'")),
146 }
147 }
148 if in_braces {
149 return Err("git ref has unclosed '{'".to_string());
150 }
151 Ok(s)
152}
153
154pub fn resolve_git_toplevel(cwd: &Path) -> Result<PathBuf, ChangedFilesError> {
156 let output = spawn_output(&mut git_command(cwd, &["rev-parse", "--show-toplevel"]))
157 .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
158
159 if !output.status.success() {
160 let stderr = String::from_utf8_lossy(&output.stderr);
161 return Err(if stderr.contains("not a git repository") {
162 ChangedFilesError::NotARepository
163 } else {
164 ChangedFilesError::GitFailed(stderr.trim().to_owned())
165 });
166 }
167
168 let raw = String::from_utf8_lossy(&output.stdout);
169 let trimmed = raw.trim();
170 if trimmed.is_empty() {
171 return Err(ChangedFilesError::GitFailed(
172 "git rev-parse --show-toplevel returned empty output".to_owned(),
173 ));
174 }
175
176 let path = PathBuf::from(trimmed);
177 Ok(dunce::canonicalize(&path).unwrap_or(path))
178}
179
180pub fn resolve_git_common_dir(cwd: &Path) -> Result<PathBuf, ChangedFilesError> {
182 let output = spawn_output(&mut git_command(
183 cwd,
184 &["rev-parse", "--path-format=absolute", "--git-common-dir"],
185 ))
186 .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
187
188 if !output.status.success() {
189 let stderr = String::from_utf8_lossy(&output.stderr);
190 return Err(if stderr.contains("not a git repository") {
191 ChangedFilesError::NotARepository
192 } else {
193 ChangedFilesError::GitFailed(stderr.trim().to_owned())
194 });
195 }
196
197 let raw = String::from_utf8_lossy(&output.stdout);
198 let trimmed = raw.trim();
199 if trimmed.is_empty() {
200 return Err(ChangedFilesError::GitFailed(
201 "git rev-parse --git-common-dir returned empty output".to_owned(),
202 ));
203 }
204
205 let path = PathBuf::from(trimmed);
206 Ok(dunce::canonicalize(&path).unwrap_or(path))
207}
208
209pub fn resolve_git_common_dir_and_toplevel(
223 cwd: &Path,
224) -> Result<(PathBuf, PathBuf), ChangedFilesError> {
225 let output = spawn_output(&mut git_command(
226 cwd,
227 &[
228 "rev-parse",
229 "--path-format=absolute",
230 "--git-common-dir",
231 "--show-toplevel",
232 ],
233 ))
234 .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
235
236 if !output.status.success() {
237 let stderr = String::from_utf8_lossy(&output.stderr);
238 return Err(if stderr.contains("not a git repository") {
239 ChangedFilesError::NotARepository
240 } else {
241 ChangedFilesError::GitFailed(stderr.trim().to_owned())
242 });
243 }
244
245 let raw = String::from_utf8_lossy(&output.stdout);
246 let mut lines = raw.lines();
247 let (Some(common), Some(toplevel), None) = (lines.next(), lines.next(), lines.next()) else {
248 return Err(ChangedFilesError::GitFailed(
249 "git rev-parse did not print one common dir and one toplevel".to_owned(),
250 ));
251 };
252 let canonical = |line: &str| {
253 let path = PathBuf::from(line.trim());
254 dunce::canonicalize(&path).unwrap_or(path)
255 };
256 if common.trim().is_empty() || toplevel.trim().is_empty() {
257 return Err(ChangedFilesError::GitFailed(
258 "git rev-parse returned an empty path".to_owned(),
259 ));
260 }
261 Ok((canonical(common), canonical(toplevel)))
262}
263
264fn try_get_changed_files(
266 root: &Path,
267 git_ref: &str,
268) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
269 validate_git_ref(git_ref).map_err(ChangedFilesError::InvalidRef)?;
270 let toplevel = resolve_git_toplevel(root)?;
271 try_get_changed_files_with_toplevel(root, &toplevel, git_ref)
272}
273
274pub fn changed_files(root: &Path, git_ref: &str) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
280 try_get_changed_files(root, git_ref)
281}
282
283pub(crate) struct ChangedFilesBatch<'a> {
287 cwd: &'a Path,
288 toplevel: PathBuf,
289 working_tree: Option<FxHashSet<PathBuf>>,
290}
291
292impl<'a> ChangedFilesBatch<'a> {
293 pub(crate) fn new(cwd: &'a Path, first_ref: &str) -> Result<Self, ChangedFilesError> {
294 validate_git_ref(first_ref).map_err(ChangedFilesError::InvalidRef)?;
295 Ok(Self {
296 cwd,
297 toplevel: resolve_git_toplevel(cwd)?,
298 working_tree: None,
299 })
300 }
301
302 pub(crate) fn changed_files(
303 &mut self,
304 git_ref: &str,
305 ) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
306 let mut files = committed_changed_files(self.cwd, &self.toplevel, git_ref)?;
307 if self.working_tree.is_none() {
308 self.working_tree = Some(working_tree_changed_files(self.cwd, &self.toplevel)?);
309 }
310 if let Some(working_tree) = &self.working_tree {
311 files.extend(working_tree.iter().cloned());
312 }
313 Ok(files)
314 }
315}
316
317pub fn try_get_changed_files_with_toplevel(
319 cwd: &Path,
320 toplevel: &Path,
321 git_ref: &str,
322) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
323 let mut files = committed_changed_files(cwd, toplevel, git_ref)?;
324 files.extend(working_tree_changed_files(cwd, toplevel)?);
325 Ok(files)
326}
327
328fn committed_changed_files(
329 cwd: &Path,
330 toplevel: &Path,
331 git_ref: &str,
332) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
333 validate_git_ref(git_ref).map_err(ChangedFilesError::InvalidRef)?;
334 collect_git_paths(
335 cwd,
336 toplevel,
337 &[
338 "diff",
339 "--name-only",
340 "-z",
341 "--end-of-options",
342 &format!("{git_ref}...HEAD"),
343 ],
344 )
345}
346
347fn working_tree_changed_files(
348 cwd: &Path,
349 toplevel: &Path,
350) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
351 let mut files = collect_git_paths(cwd, toplevel, &["diff", "--name-only", "-z", "HEAD"])?;
352 files.extend(collect_git_paths(
353 cwd,
354 toplevel,
355 &[
356 "ls-files",
357 "--full-name",
358 "--others",
359 "--exclude-standard",
360 "-z",
361 ],
362 )?);
363 Ok(files)
364}
365
366#[derive(Debug, Clone, PartialEq, Eq)]
369pub struct RenamedFile {
370 pub from: PathBuf,
372 pub to: PathBuf,
374}
375
376pub fn try_get_renamed_files(
388 root: &Path,
389 git_ref: &str,
390) -> Result<Vec<RenamedFile>, ChangedFilesError> {
391 validate_git_ref(git_ref).map_err(ChangedFilesError::InvalidRef)?;
392 let toplevel = resolve_git_toplevel(root)?;
393 let mut renames = collect_git_rename_pairs(
394 root,
395 &toplevel,
396 &[
397 "diff",
398 "--name-status",
399 "-z",
400 "--find-renames",
401 "--end-of-options",
402 &format!("{git_ref}...HEAD"),
403 ],
404 )?;
405 let staged = collect_git_rename_pairs(
406 root,
407 &toplevel,
408 &["diff", "--name-status", "-z", "--find-renames", "HEAD"],
409 )?;
410 for pair in staged {
411 if let Some(chained) = renames.iter_mut().find(|rename| rename.to == pair.from) {
412 chained.to = pair.to;
413 } else {
414 renames.push(pair);
415 }
416 }
417 Ok(renames)
418}
419
420fn collect_git_rename_pairs(
426 cwd: &Path,
427 toplevel: &Path,
428 args: &[&str],
429) -> Result<Vec<RenamedFile>, ChangedFilesError> {
430 let output = spawn_output(&mut git_command(cwd, args))
431 .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
432
433 if !output.status.success() {
434 return Err(changed_files_error_from_output(&output));
435 }
436
437 let mut fields = output
438 .stdout
439 .split(|byte| *byte == 0)
440 .filter(|field| !field.is_empty());
441 let mut renames = Vec::new();
442 while let Some(status) = fields.next() {
443 let Some(first_path) = fields.next() else {
444 break;
445 };
446 match status.first() {
447 Some(b'R') => {
448 let Some(second_path) = fields.next() else {
449 break;
450 };
451 renames.push(RenamedFile {
452 from: toplevel.join(git_path_from_bytes(first_path)),
453 to: toplevel.join(git_path_from_bytes(second_path)),
454 });
455 }
456 Some(b'C') => {
459 let _ = fields.next();
460 }
461 _ => {}
462 }
463 }
464 Ok(renames)
465}
466
467pub fn try_get_changed_diff(root: &Path, git_ref: &str) -> Result<String, ChangedFilesError> {
472 validate_git_ref(git_ref).map_err(ChangedFilesError::InvalidRef)?;
473 let toplevel = resolve_git_toplevel(root)?;
474 let merge_base_output = spawn_output(&mut git_command(root, &["merge-base", git_ref, "HEAD"]))
475 .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
476 if !merge_base_output.status.success() {
477 return Err(changed_files_error_from_output(&merge_base_output));
478 }
479 let merge_base = String::from_utf8_lossy(&merge_base_output.stdout)
480 .trim()
481 .to_owned();
482 if merge_base.is_empty() {
483 return Err(ChangedFilesError::GitFailed(
484 "git merge-base returned empty output".to_owned(),
485 ));
486 }
487
488 let output = spawn_output(&mut git_command(
489 root,
490 &[
491 "diff",
492 "--relative",
493 "--unified=0",
494 "--end-of-options",
495 &merge_base,
496 ],
497 ))
498 .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
499
500 if !output.status.success() {
501 return Err(changed_files_error_from_output(&output));
502 }
503
504 let mut diff = String::from_utf8_lossy(&output.stdout).into_owned();
505 append_untracked_diffs(root, &toplevel, &mut diff)?;
506 Ok(diff)
507}
508
509fn append_untracked_diffs(
510 root: &Path,
511 toplevel: &Path,
512 diff: &mut String,
513) -> Result<(), ChangedFilesError> {
514 let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
515 let mut untracked: Vec<PathBuf> = collect_git_paths(
516 root,
517 toplevel,
518 &[
519 "ls-files",
520 "--full-name",
521 "--others",
522 "--exclude-standard",
523 "-z",
524 ],
525 )?
526 .into_iter()
527 .filter_map(|path| {
528 path.strip_prefix(&canonical_root)
529 .ok()
530 .map(Path::to_path_buf)
531 })
532 .collect();
533 untracked.sort_unstable();
534
535 #[cfg(windows)]
536 let empty_file = "NUL";
537 #[cfg(not(windows))]
538 let empty_file = "/dev/null";
539
540 for path in untracked {
541 let mut command = git_command(root, &["diff", "--no-index", "--unified=0", "--"]);
542 command.arg(empty_file).arg(untracked_path_arg(&path));
543 let output =
544 spawn_output(&mut command).map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
545 if !output.status.success() && output.status.code() != Some(1) {
546 return Err(changed_files_error_from_output(&output));
547 }
548 if !diff.is_empty() && !diff.ends_with('\n') {
549 diff.push('\n');
550 }
551 diff.push_str(&String::from_utf8_lossy(&output.stdout));
552 }
553 Ok(())
554}
555
556fn untracked_path_arg(path: &Path) -> String {
561 path.to_string_lossy().replace('\\', "/")
562}
563
564fn changed_files_error_from_output(output: &Output) -> ChangedFilesError {
565 let stderr = String::from_utf8_lossy(&output.stderr);
566 if stderr.contains("not a git repository") {
567 ChangedFilesError::NotARepository
568 } else {
569 ChangedFilesError::GitFailed(stderr.trim().to_owned())
570 }
571}
572
573#[must_use]
575#[expect(
576 clippy::print_stderr,
577 reason = "intentional user-facing warning for the CLI's --changed-since fallback path; typed callers use try_get_changed_files instead"
578)]
579pub fn get_changed_files(root: &Path, git_ref: &str) -> Option<FxHashSet<PathBuf>> {
580 match try_get_changed_files(root, git_ref) {
581 Ok(files) => Some(files),
582 Err(err) => {
583 eprintln!("Warning: {}", err.changed_since_message(git_ref));
584 None
585 }
586 }
587}
588
589fn spawn_output(command: &mut Command) -> std::io::Result<Output> {
590 if let Some(hook) = SPAWN_HOOK.get() {
591 hook(command)
592 } else {
593 command.output()
594 }
595}
596
597fn collect_git_paths(
598 cwd: &Path,
599 toplevel: &Path,
600 args: &[&str],
601) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
602 let output = spawn_output(&mut git_command(cwd, args))
603 .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
604
605 if !output.status.success() {
606 let stderr = String::from_utf8_lossy(&output.stderr);
607 return Err(if stderr.contains("not a git repository") {
608 ChangedFilesError::NotARepository
609 } else {
610 ChangedFilesError::GitFailed(stderr.trim().to_owned())
611 });
612 }
613
614 let files = output
615 .stdout
616 .split(|byte| *byte == 0)
617 .filter(|path| !path.is_empty())
618 .map(git_path_from_bytes)
619 .map(|path| toplevel.join(path))
620 .collect();
621
622 Ok(files)
623}
624
625#[cfg(unix)]
631pub(crate) fn git_path_from_bytes(path: &[u8]) -> PathBuf {
632 use std::ffi::OsString;
633 use std::os::unix::ffi::OsStringExt;
634
635 PathBuf::from(OsString::from_vec(path.to_vec()))
636}
637
638#[cfg(windows)]
641pub(crate) fn git_path_from_bytes(path: &[u8]) -> PathBuf {
642 PathBuf::from(String::from_utf8_lossy(path).replace('/', "\\"))
643}
644
645#[expect(
646 clippy::disallowed_methods,
647 reason = "canonical engine-owned git spawn wrapper for changed-file orchestration"
648)]
649fn git_command(cwd: &Path, args: &[&str]) -> Command {
650 let mut command = Command::new("git");
651 clear_ambient_git_env(&mut command);
652 command.stdin(Stdio::null()).args(args).current_dir(cwd);
654 command
655}
656
657#[expect(
662 clippy::implicit_hasher,
663 reason = "fallow standardizes on FxHashSet across the workspace"
664)]
665pub fn filter_results_by_changed_files(
666 results: &mut AnalysisResults,
667 changed_files: &FxHashSet<PathBuf>,
668) {
669 filter_results_by_path_scope(results, &NormalizedChangedFiles::new(changed_files));
670}
671
672pub(crate) trait ChangedPathScope {
675 fn contains(&self, path: &Path) -> bool;
676 fn contains_workspace(&self, path: &Path) -> bool {
677 self.contains(path)
678 }
679}
680
681#[derive(Debug, Clone, Default)]
683pub(crate) struct NormalizedChangedFiles(FxHashSet<PathBuf>);
684
685impl NormalizedChangedFiles {
686 pub(crate) fn new(changed_files: &FxHashSet<PathBuf>) -> Self {
687 Self(normalize_changed_files_set(changed_files))
688 }
689}
690
691impl ChangedPathScope for NormalizedChangedFiles {
692 fn contains(&self, path: &Path) -> bool {
693 contains_normalized(&self.0, path)
694 }
695
696 fn contains_workspace(&self, path: &Path) -> bool {
697 normalized_set_contains_path(&self.0, path)
698 }
699}
700
701pub(crate) fn filter_results_by_path_scope(
702 results: &mut AnalysisResults,
703 scope: &impl ChangedPathScope,
704) {
705 classify_changed_file_filter_fields(results);
706 retain_basic_issue_findings_by_changed_path(results, scope);
707 retain_graph_findings_by_changed_files(results, scope);
708 retain_boundary_policy_and_suppression_findings(results, scope);
709 retain_security_and_workspace_findings(results, scope);
710 retain_framework_findings_by_changed_files(results, scope);
711}
712
713fn classify_changed_file_filter_fields(results: &AnalysisResults) {
714 let AnalysisResults {
715 unused_files: _unused_files,
716 unused_exports: _unused_exports,
717 unused_types: _unused_types,
718 private_type_leaks: _private_type_leaks,
719 deprecated_exports_in_use: _deprecated_exports_in_use,
720 unused_dependencies: _unused_dependencies,
721 unused_dev_dependencies: _unused_dev_dependencies,
722 unused_optional_dependencies: _unused_optional_dependencies,
723 unused_enum_members: _unused_enum_members,
724 unused_class_members: _unused_class_members,
725 unused_store_members: _unused_store_members,
726 unresolved_imports: _unresolved_imports,
727 unlisted_dependencies: _unlisted_dependencies,
728 duplicate_exports: _duplicate_exports,
729 type_only_dependencies: _type_only_dependencies,
730 test_only_dependencies: _test_only_dependencies,
731 dev_dependencies_in_production: _dev_dependencies_in_production,
732 circular_dependencies: _circular_dependencies,
733 re_export_cycles: _re_export_cycles,
734 package_cycles: _package_cycles,
735 boundary_violations: _boundary_violations,
736 boundary_coverage_violations: _boundary_coverage_violations,
737 boundary_call_violations: _boundary_call_violations,
738 policy_violations: _policy_violations,
739 stale_suppressions: _stale_suppressions,
740 unused_catalog_entries: _unused_catalog_entries,
741 empty_catalog_groups: _empty_catalog_groups,
742 unresolved_catalog_references: _unresolved_catalog_references,
743 unused_dependency_overrides: _unused_dependency_overrides,
744 misconfigured_dependency_overrides: _misconfigured_dependency_overrides,
745 invalid_client_exports: _invalid_client_exports,
746 mixed_client_server_barrels: _mixed_client_server_barrels,
747 misplaced_directives: _misplaced_directives,
748 unprovided_injects: _unprovided_injects,
749 unrendered_components: _unrendered_components,
750 route_collisions: _route_collisions,
751 dynamic_segment_name_conflicts: _dynamic_segment_name_conflicts,
752 unused_component_props: _unused_component_props,
753 absent_component_props: _absent_component_props,
754 unused_component_emits: _unused_component_emits,
755 unused_component_inputs: _unused_component_inputs,
756 unused_component_outputs: _unused_component_outputs,
757 unused_svelte_events: _unused_svelte_events,
758 unused_effect_layers: _unused_effect_layers,
759 unused_tagged_errors: _unused_tagged_errors,
760 unproduced_catch_tags: _unproduced_catch_tags,
761 unused_server_actions: _unused_server_actions,
762 unused_load_data_keys: _unused_load_data_keys,
763 unused_load_data_keys_global_abstain: _unused_load_data_keys_global_abstain,
764 prop_drilling_chains: _prop_drilling_chains,
765 thin_wrappers: _thin_wrappers,
766 duplicate_prop_shapes: _duplicate_prop_shapes,
767 cascade_hidden: _cascade_hidden,
768 cascade: _cascade,
769 suppression_count: _suppression_count,
770 unused_component_props_exempted: _unused_component_props_exempted,
771 active_suppressions: _active_suppressions,
772 feature_flags: _feature_flags,
773 security_findings: _security_findings,
774 security_unresolved_edge_files: _security_unresolved_edge_files,
775 security_unresolved_callee_sites: _security_unresolved_callee_sites,
776 security_unresolved_callee_diagnostics: _security_unresolved_callee_diagnostics,
777 export_usages: _export_usages,
778 entry_point_summary: _entry_point_summary,
779 render_fan_in: _render_fan_in,
780 react_component_intel: _react_component_intel,
781 semantic_framework_contracts: _semantic_framework_contracts,
782 } = results;
783}
784
785fn retain_basic_issue_findings_by_changed_path(
786 results: &mut AnalysisResults,
787 scope: &impl ChangedPathScope,
788) {
789 retain_by_changed_path(&mut results.unused_files, scope, |f| &f.file.path);
790 retain_by_changed_path(&mut results.unused_exports, scope, |e| &e.export.path);
791 retain_by_changed_path(&mut results.unused_types, scope, |e| &e.export.path);
792 retain_by_changed_path(&mut results.private_type_leaks, scope, |e| &e.leak.path);
793 retain_by_changed_path(&mut results.deprecated_exports_in_use, scope, |e| {
794 &e.export.path
795 });
796 retain_by_changed_path(&mut results.unused_enum_members, scope, |m| &m.member.path);
797 retain_by_changed_path(&mut results.unused_class_members, scope, |m| &m.member.path);
798 retain_by_changed_path(&mut results.unused_store_members, scope, |m| &m.member.path);
799 retain_by_changed_path(&mut results.unresolved_imports, scope, |i| &i.import.path);
800}
801
802fn retain_graph_findings_by_changed_files(
803 results: &mut AnalysisResults,
804 scope: &impl ChangedPathScope,
805) {
806 retain_unlisted_dependencies_by_import_site(&mut results.unlisted_dependencies, scope);
807 retain_duplicate_exports_by_changed_locations(&mut results.duplicate_exports, scope);
808 retain_circular_dependencies_by_changed_file(&mut results.circular_dependencies, scope);
809 retain_re_export_cycles_by_changed_file(&mut results.re_export_cycles, scope);
810 retain_package_cycles_by_changed_file(&mut results.package_cycles, scope);
811}
812
813fn retain_boundary_policy_and_suppression_findings(
814 results: &mut AnalysisResults,
815 scope: &impl ChangedPathScope,
816) {
817 retain_by_changed_path(&mut results.boundary_violations, scope, |v| {
818 &v.violation.from_path
819 });
820 retain_by_changed_path(&mut results.boundary_coverage_violations, scope, |v| {
821 &v.violation.path
822 });
823 retain_by_changed_path(&mut results.boundary_call_violations, scope, |v| {
824 &v.violation.path
825 });
826 retain_by_changed_path(&mut results.policy_violations, scope, |v| &v.violation.path);
827 retain_by_changed_path(&mut results.stale_suppressions, scope, |s| &s.path);
828}
829
830fn retain_security_and_workspace_findings(
831 results: &mut AnalysisResults,
832 scope: &impl ChangedPathScope,
833) {
834 retain_security_findings_by_changed_path(&mut results.security_findings, scope);
835 retain_by_changed_path(
836 &mut results.security_unresolved_callee_diagnostics,
837 scope,
838 |d| &d.path,
839 );
840 retain_by_changed_path(&mut results.unresolved_catalog_references, scope, |r| {
841 &r.reference.path
842 });
843 results
844 .empty_catalog_groups
845 .retain(|g| scope.contains_workspace(&g.group.path));
846 retain_by_changed_path(&mut results.unused_dependency_overrides, scope, |o| {
847 &o.entry.path
848 });
849 retain_by_changed_path(
850 &mut results.misconfigured_dependency_overrides,
851 scope,
852 |o| &o.entry.path,
853 );
854}
855
856fn retain_framework_findings_by_changed_files(
857 results: &mut AnalysisResults,
858 scope: &impl ChangedPathScope,
859) {
860 retain_client_boundary_findings_by_changed_files(results, scope);
861 retain_component_contract_findings_by_changed_files(results, scope);
862 retain_react_health_findings_by_changed_files(results, scope);
863 retain_nextjs_findings_by_changed_files(results, scope);
864}
865
866fn retain_client_boundary_findings_by_changed_files(
867 results: &mut AnalysisResults,
868 scope: &impl ChangedPathScope,
869) {
870 let AnalysisResults {
871 invalid_client_exports,
872 mixed_client_server_barrels,
873 misplaced_directives,
874 ..
875 } = results;
876
877 retain_by_changed_path(invalid_client_exports, scope, |e| &e.export.path);
878 retain_by_changed_path(mixed_client_server_barrels, scope, |b| &b.barrel.path);
879 retain_by_changed_path(misplaced_directives, scope, |d| &d.directive_site.path);
880}
881
882fn retain_component_contract_findings_by_changed_files(
883 results: &mut AnalysisResults,
884 scope: &impl ChangedPathScope,
885) {
886 let AnalysisResults {
887 unprovided_injects,
888 unrendered_components,
889 unused_component_props,
890 absent_component_props,
891 unused_component_emits,
892 unused_component_inputs,
893 unused_component_outputs,
894 unused_svelte_events,
895 unused_effect_layers,
896 unused_tagged_errors,
897 unproduced_catch_tags,
898 unused_server_actions,
899 unused_load_data_keys,
900 ..
901 } = results;
902
903 retain_by_changed_path(unprovided_injects, scope, |i| &i.inject.path);
904 retain_by_changed_path(unrendered_components, scope, |c| &c.component.path);
905 retain_by_changed_path(unused_component_props, scope, |p| &p.prop.path);
906 retain_by_changed_path(absent_component_props, scope, |p| &p.prop.path);
907 retain_by_changed_path(unused_component_emits, scope, |e| &e.emit.path);
908 retain_by_changed_path(unused_component_inputs, scope, |i| &i.input.path);
909 retain_by_changed_path(unused_component_outputs, scope, |o| &o.output.path);
910 retain_by_changed_path(unused_svelte_events, scope, |e| &e.event.path);
911 retain_by_changed_path(unused_effect_layers, scope, |e| &e.layer.path);
912 retain_by_changed_path(unused_tagged_errors, scope, |e| &e.error.path);
913 retain_by_changed_path(unproduced_catch_tags, scope, |e| &e.catch_tag.path);
914 retain_by_changed_path(unused_server_actions, scope, |a| &a.action.path);
915 retain_by_changed_path(unused_load_data_keys, scope, |k| &k.key.path);
916}
917
918fn retain_react_health_findings_by_changed_files(
919 results: &mut AnalysisResults,
920 scope: &impl ChangedPathScope,
921) {
922 let AnalysisResults {
923 prop_drilling_chains,
924 thin_wrappers,
925 duplicate_prop_shapes,
926 ..
927 } = results;
928
929 retain_prop_drilling_chains_by_anchor(prop_drilling_chains, scope);
930 retain_by_changed_path(thin_wrappers, scope, |w| &w.wrapper.file);
931 retain_duplicate_prop_shapes_by_anchor(duplicate_prop_shapes, scope);
932}
933
934fn retain_nextjs_findings_by_changed_files(
935 results: &mut AnalysisResults,
936 scope: &impl ChangedPathScope,
937) {
938 let AnalysisResults {
939 route_collisions,
940 dynamic_segment_name_conflicts,
941 ..
942 } = results;
943
944 retain_by_changed_path(route_collisions, scope, |c| &c.collision.path);
945 retain_by_changed_path(dynamic_segment_name_conflicts, scope, |c| &c.conflict.path);
946}
947
948fn retain_unlisted_dependencies_by_import_site(
949 dependencies: &mut Vec<UnlistedDependencyFinding>,
950 scope: &impl ChangedPathScope,
951) {
952 dependencies.retain(|dependency| {
953 dependency
954 .dep
955 .imported_from
956 .iter()
957 .any(|site| scope.contains(&site.path))
958 });
959}
960
961fn retain_duplicate_exports_by_changed_locations(
962 duplicate_exports: &mut Vec<DuplicateExportFinding>,
963 scope: &impl ChangedPathScope,
964) {
965 for duplicate in &mut *duplicate_exports {
966 duplicate
967 .export
968 .locations
969 .retain(|location| scope.contains(&location.path));
970 }
971 duplicate_exports.retain(|duplicate| duplicate.export.locations.len() >= 2);
972}
973
974fn retain_circular_dependencies_by_changed_file(
975 cycles: &mut Vec<CircularDependencyFinding>,
976 scope: &impl ChangedPathScope,
977) {
978 cycles.retain(|cycle| cycle.cycle.files.iter().any(|file| scope.contains(file)));
979}
980
981fn retain_re_export_cycles_by_changed_file(
982 cycles: &mut Vec<ReExportCycleFinding>,
983 scope: &impl ChangedPathScope,
984) {
985 cycles.retain(|cycle| cycle.cycle.files.iter().any(|file| scope.contains(file)));
986}
987
988fn retain_package_cycles_by_changed_file(
989 cycles: &mut Vec<PackageCycleFinding>,
990 scope: &impl ChangedPathScope,
991) {
992 cycles.retain(|cycle| {
993 cycle
994 .cycle
995 .edges
996 .iter()
997 .any(|edge| scope.contains(&edge.path))
998 });
999}
1000
1001fn retain_security_findings_by_changed_path(
1002 findings: &mut Vec<SecurityFinding>,
1003 scope: &impl ChangedPathScope,
1004) {
1005 findings.retain(|finding| security_finding_touches_changed_path(finding, scope));
1006}
1007
1008fn retain_prop_drilling_chains_by_anchor(
1009 chains: &mut Vec<PropDrillingChainFinding>,
1010 scope: &impl ChangedPathScope,
1011) {
1012 chains.retain(|chain| {
1013 if chain.effective_severity
1014 == Some(fallow_types::output_dead_code::EffectiveSeverity::Error)
1015 {
1016 return chain.chain.hops.is_empty()
1017 || chain.chain.hops.iter().any(|hop| scope.contains(&hop.file));
1018 }
1019 chain
1020 .chain
1021 .hops
1022 .first()
1023 .is_some_and(|hop| scope.contains(&hop.file))
1024 });
1025}
1026
1027fn retain_duplicate_prop_shapes_by_anchor(
1028 shapes: &mut Vec<DuplicatePropShapeFinding>,
1029 scope: &impl ChangedPathScope,
1030) {
1031 retain_by_changed_path(shapes, scope, |shape| &shape.shape.file);
1032}
1033
1034fn retain_by_changed_path<T>(
1035 items: &mut Vec<T>,
1036 scope: &impl ChangedPathScope,
1037 path: impl Fn(&T) -> &Path,
1038) {
1039 items.retain(|item| scope.contains(path(item)));
1040}
1041
1042fn security_finding_touches_changed_path(
1043 finding: &SecurityFinding,
1044 scope: &impl ChangedPathScope,
1045) -> bool {
1046 scope.contains(&finding.path)
1047 || finding.trace.iter().any(|hop| scope.contains(&hop.path))
1048 || finding.reachability.as_ref().is_some_and(|reachability| {
1049 reachability
1050 .untrusted_source_trace
1051 .iter()
1052 .any(|hop| scope.contains(&hop.path))
1053 })
1054}
1055
1056fn normalize_changed_files_set(changed_files: &FxHashSet<PathBuf>) -> FxHashSet<PathBuf> {
1057 changed_files
1058 .iter()
1059 .map(|p| dunce::simplified(p).to_path_buf())
1060 .collect()
1061}
1062
1063fn contains_normalized(normalized: &FxHashSet<PathBuf>, path: &Path) -> bool {
1064 normalized.contains(dunce::simplified(path))
1065}
1066
1067fn normalized_set_contains_path(normalized: &FxHashSet<PathBuf>, path: &Path) -> bool {
1068 contains_normalized(normalized, path)
1069 || (path.is_relative() && normalized.iter().any(|changed| changed.ends_with(path)))
1070}
1071
1072#[expect(
1074 clippy::implicit_hasher,
1075 reason = "fallow standardizes on FxHashSet across the workspace"
1076)]
1077pub fn filter_duplication_by_changed_files(
1078 report: &mut DuplicationReport,
1079 changed_files: &FxHashSet<PathBuf>,
1080 root: &Path,
1081) {
1082 filter_duplication_by_path_scope(report, &NormalizedChangedFiles::new(changed_files), root);
1083}
1084
1085pub(crate) fn filter_duplication_by_path_scope(
1086 report: &mut DuplicationReport,
1087 scope: &impl ChangedPathScope,
1088 root: &Path,
1089) {
1090 report.clone_groups.retain(|group| {
1091 group
1092 .instances
1093 .iter()
1094 .any(|instance| scope.contains(&instance.file))
1095 });
1096 duplicates::refresh_scoped_report(report, root);
1097}
1098
1099#[cfg(test)]
1100mod tests {
1101 use super::*;
1102 use fallow_types::{
1103 duplicates::{CloneGroup, CloneInstance, DuplicationStats},
1104 output_dead_code::{
1105 EmptyCatalogGroupFinding, UnusedDependencyFinding, UnusedExportFinding,
1106 UnusedFileFinding,
1107 },
1108 results::{
1109 DependencyLocation, EmptyCatalogGroup, UnusedDependency, UnusedExport, UnusedFile,
1110 },
1111 };
1112
1113 #[test]
1116 fn every_changed_files_cause_reports_its_own_reason() {
1117 let reasons = [
1118 ChangedFilesError::InvalidRef("unclosed brace".to_owned()).reason(),
1119 ChangedFilesError::GitMissing("no such file".to_owned()).reason(),
1120 ChangedFilesError::NotARepository.reason(),
1121 ChangedFilesError::GitFailed("unknown revision".to_owned()).reason(),
1122 ];
1123 assert_eq!(
1124 reasons,
1125 [
1126 "invalid-ref",
1127 "git-missing",
1128 "not-a-repository",
1129 "git-failed"
1130 ]
1131 );
1132 let unique: std::collections::BTreeSet<&str> = reasons.iter().copied().collect();
1133 assert_eq!(
1134 unique.len(),
1135 reasons.len(),
1136 "two causes must not share a token"
1137 );
1138 }
1139
1140 #[test]
1145 fn the_changed_since_message_names_the_ref_the_widening_and_the_next_step() {
1146 let message = ChangedFilesError::NotARepository.changed_since_message("origin/main");
1147 assert!(
1148 message.contains("--changed-since 'origin/main'"),
1149 "{message}"
1150 );
1151 assert!(message.contains("covers the whole project"), "{message}");
1152 assert!(message.ends_with("or drop --changed-since."), "{message}");
1153 }
1154
1155 #[test]
1159 fn the_changed_since_message_folds_git_stderr_onto_one_line() {
1160 let message = ChangedFilesError::GitFailed(
1161 "fatal: ambiguous argument 'x'\nUse '--' to separate paths".to_owned(),
1162 )
1163 .changed_since_message("x");
1164 assert!(!message.contains('\n'), "{message}");
1165 assert!(
1166 message.contains("ambiguous argument 'x' Use '--'"),
1167 "{message}"
1168 );
1169 }
1170
1171 #[test]
1172 fn git_command_clears_parent_git_environment() {
1173 let command = git_command(Path::new("."), &["status"]);
1174 let envs: Vec<_> = command.get_envs().collect();
1175
1176 for var in AMBIENT_GIT_ENV_VARS {
1177 assert!(
1178 envs.iter()
1179 .any(|(key, value)| key.to_str() == Some(*var) && value.is_none()),
1180 "{var} should be cleared from the command env",
1181 );
1182 }
1183 }
1184
1185 #[test]
1186 fn try_get_changed_files_not_a_repository() {
1187 let temp = tempfile::tempdir().expect("tempdir");
1188 let result = try_get_changed_files(temp.path(), "main");
1189 assert!(matches!(result, Err(ChangedFilesError::NotARepository)));
1190 }
1191
1192 #[test]
1193 fn batched_refs_match_independent_changed_file_scopes() {
1194 let repo = tempfile::tempdir().expect("tempdir");
1195 for args in [
1196 &["init", "--quiet"][..],
1197 &["config", "user.email", "test@example.com"][..],
1198 &["config", "user.name", "Test User"][..],
1199 &["config", "commit.gpgsign", "false"][..],
1200 ] {
1201 run_git(repo.path(), args);
1202 }
1203 for name in ["committed.ts", "staged.ts", "unstaged.ts"] {
1204 std::fs::write(repo.path().join(name), "old\n").expect("initial file");
1205 }
1206 run_git(repo.path(), &["add", "."]);
1207 run_git(repo.path(), &["commit", "--quiet", "-m", "base"]);
1208 run_git(repo.path(), &["branch", "base"]);
1209
1210 std::fs::write(repo.path().join("committed.ts"), "new\n").expect("committed change");
1211 run_git(repo.path(), &["add", "committed.ts"]);
1212 run_git(repo.path(), &["commit", "--quiet", "-m", "change"]);
1213 std::fs::write(repo.path().join("staged.ts"), "new\n").expect("staged change");
1214 run_git(repo.path(), &["add", "staged.ts"]);
1215 std::fs::write(repo.path().join("unstaged.ts"), "new\n").expect("unstaged change");
1216 std::fs::write(repo.path().join("untracked.ts"), "new\n").expect("untracked file");
1217
1218 let mut batch = ChangedFilesBatch::new(repo.path(), "base").expect("batch");
1219 for reference in ["base", "HEAD"] {
1220 let batched = batch.changed_files(reference).expect("batched changes");
1221 let independent = changed_files(repo.path(), reference).expect("independent changes");
1222 assert_eq!(batched, independent, "{reference} scope changed");
1223 }
1224 }
1225
1226 #[cfg(unix)]
1227 #[test]
1228 fn changed_files_preserve_special_filenames() {
1229 let repo = tempfile::tempdir().expect("tempdir");
1230 for args in [
1231 &["init", "--quiet"][..],
1232 &["config", "user.email", "test@example.com"][..],
1233 &["config", "user.name", "Test User"][..],
1234 &["config", "commit.gpgsign", "false"][..],
1235 &["config", "tag.gpgsign", "false"][..],
1236 ] {
1237 run_git(repo.path(), args);
1238 }
1239 std::fs::write(repo.path().join("initial.ts"), "initial\n").expect("initial fixture");
1240 run_git(repo.path(), &["add", "."]);
1241 run_git(repo.path(), &["commit", "--quiet", "-m", "initial"]);
1242 run_git(repo.path(), &["tag", "base"]);
1243
1244 let canonical_root = repo.path().canonicalize().expect("canonical repo");
1245 let special_files = [
1246 "src/line\nbreak.ts",
1247 "src/space name.ts",
1248 "src/quote\"name.ts",
1249 "src/back\\slash.ts",
1250 "src/unicode-λ.ts",
1251 ]
1252 .map(|path| canonical_root.join(path));
1253 std::fs::create_dir_all(canonical_root.join("src")).expect("source dir");
1254 for special in &special_files {
1255 std::fs::write(special, "changed\n").expect("special fixture");
1256 }
1257
1258 let changed = try_get_changed_files(repo.path(), "base").expect("changed files");
1259 for special in special_files {
1260 assert!(
1261 changed.contains(&special),
1262 "missing {special:?}: {changed:?}"
1263 );
1264 }
1265 }
1266
1267 #[cfg(windows)]
1268 #[test]
1269 fn git_path_bytes_use_windows_separators() {
1270 assert_eq!(
1271 git_path_from_bytes(b"src/nested/file.ts"),
1272 PathBuf::from(r"src\nested\file.ts")
1273 );
1274 }
1275
1276 #[test]
1277 fn changed_diff_covers_staged_unstaged_and_untracked_files() {
1278 let repo = tempfile::tempdir().expect("tempdir");
1279 for args in [
1280 &["init", "--quiet"][..],
1281 &["config", "user.email", "test@example.com"][..],
1282 &["config", "user.name", "Test User"][..],
1283 &["config", "commit.gpgsign", "false"][..],
1284 &["config", "tag.gpgsign", "false"][..],
1285 ] {
1286 run_git(repo.path(), args);
1287 }
1288 std::fs::write(repo.path().join("staged.ts"), "old\n").expect("staged fixture");
1289 std::fs::write(repo.path().join("unstaged.ts"), "old\n").expect("unstaged fixture");
1290 run_git(repo.path(), &["add", "."]);
1291 run_git(repo.path(), &["commit", "--quiet", "-m", "initial"]);
1292 run_git(repo.path(), &["tag", "base"]);
1293
1294 std::fs::write(repo.path().join("committed.ts"), "committed\n").expect("committed fixture");
1295 run_git(repo.path(), &["add", "committed.ts"]);
1296 run_git(
1297 repo.path(),
1298 &["commit", "--quiet", "-m", "committed change"],
1299 );
1300
1301 std::fs::write(repo.path().join("staged.ts"), "staged\n").expect("staged edit");
1302 run_git(repo.path(), &["add", "staged.ts"]);
1303 std::fs::write(repo.path().join("unstaged.ts"), "unstaged\n").expect("unstaged edit");
1304 std::fs::write(repo.path().join("untracked.ts"), "untracked\n").expect("untracked edit");
1305
1306 let diff = try_get_changed_diff(repo.path(), "base").expect("complete changeset diff");
1307 let index = fallow_output::DiffIndex::from_unified_diff(&diff);
1308
1309 assert!(diff.contains("b/committed.ts"), "{diff}");
1310 assert!(diff.contains("b/staged.ts"), "{diff}");
1311 assert!(diff.contains("b/unstaged.ts"), "{diff}");
1312 assert!(diff.contains("b/untracked.ts"), "{diff}");
1313 assert_eq!(index.hunk_count(), 4);
1314 assert_eq!(index.net_lines(), 2);
1315 }
1316
1317 fn run_git(root: &Path, args: &[&str]) {
1318 let output = spawn_output(&mut git_command(root, args)).expect("git command");
1319 assert!(
1320 output.status.success(),
1321 "git {args:?} failed: {}",
1322 String::from_utf8_lossy(&output.stderr)
1323 );
1324 }
1325
1326 #[test]
1327 fn untracked_path_arg_uses_forward_slashes() {
1328 assert_eq!(
1329 super::untracked_path_arg(Path::new("src\\nested\\b.ts")),
1330 "src/nested/b.ts"
1331 );
1332 assert_eq!(super::untracked_path_arg(Path::new("src/b.ts")), "src/b.ts");
1333 }
1334
1335 #[test]
1336 fn changed_files_error_describe_matches_core_contract() {
1337 assert_eq!(
1338 ChangedFilesError::InvalidRef("bad ref".to_string()).describe(),
1339 "invalid git ref: bad ref"
1340 );
1341 assert_eq!(
1342 ChangedFilesError::GitMissing("not found".to_string()).describe(),
1343 "failed to run git: not found"
1344 );
1345 assert_eq!(
1346 ChangedFilesError::NotARepository.describe(),
1347 "not a git repository"
1348 );
1349 assert!(
1350 ChangedFilesError::GitFailed("unknown revision main".to_string())
1351 .describe()
1352 .contains("fetch-depth: 0")
1353 );
1354 }
1355
1356 #[test]
1357 fn filter_results_keeps_only_changed_file_findings() {
1358 let mut results = AnalysisResults::default();
1359 results
1360 .unused_files
1361 .push(UnusedFileFinding::with_actions(UnusedFile {
1362 path: PathBuf::from("/repo/a.ts"),
1363 }));
1364 results
1365 .unused_files
1366 .push(UnusedFileFinding::with_actions(UnusedFile {
1367 path: PathBuf::from("/repo/b.ts"),
1368 }));
1369 results
1370 .unused_exports
1371 .push(UnusedExportFinding::with_actions(UnusedExport {
1372 path: PathBuf::from("/repo/a.ts"),
1373 export_name: "foo".to_owned(),
1374 is_type_only: false,
1375 line: 1,
1376 col: 0,
1377 span_start: 0,
1378 is_re_export: false,
1379 deprecated: false,
1380 deprecated_reason: None,
1381 }));
1382
1383 let mut changed = FxHashSet::default();
1384 changed.insert(PathBuf::from("/repo/a.ts"));
1385
1386 filter_results_by_changed_files(&mut results, &changed);
1387
1388 assert_eq!(results.unused_files.len(), 1);
1389 assert_eq!(
1390 results.unused_files[0].file.path,
1391 PathBuf::from("/repo/a.ts")
1392 );
1393 assert_eq!(results.unused_exports.len(), 1);
1394 }
1395
1396 #[test]
1397 fn filter_results_preserves_graph_global_dependency_findings() {
1398 let mut results = AnalysisResults::default();
1399 results
1400 .unused_dependencies
1401 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1402 package_name: "lodash".to_owned(),
1403 location: DependencyLocation::Dependencies,
1404 path: PathBuf::from("/repo/package.json"),
1405 line: 3,
1406 used_in_workspaces: Vec::new(),
1407 declared_and_imported_in: Vec::new(),
1408 }));
1409
1410 let changed = FxHashSet::default();
1411 filter_results_by_changed_files(&mut results, &changed);
1412
1413 assert_eq!(results.unused_dependencies.len(), 1);
1414 }
1415
1416 #[test]
1420 fn filter_results_keeps_deprecated_export_only_when_its_declaring_file_changed() {
1421 let finding = |path: &str| {
1422 fallow_types::output_dead_code::DeprecatedExportInUseFinding::with_actions(
1423 fallow_types::results::DeprecatedExportInUse {
1424 path: PathBuf::from(path),
1425 export_name: "old".to_owned(),
1426 is_type_only: false,
1427 line: 2,
1428 col: 0,
1429 span_start: 0,
1430 deprecated_reason: None,
1431 consumer_count: 1,
1432 consumers: vec![fallow_types::results::DeprecatedExportConsumer {
1433 path: PathBuf::from("/repo/consumer.ts"),
1434 line: 1,
1435 col: 0,
1436 kind: fallow_types::results::DeprecatedConsumerKind::NamedImport,
1437 }],
1438 public_api: false,
1439 },
1440 )
1441 };
1442 let mut results = AnalysisResults::default();
1443 results
1444 .deprecated_exports_in_use
1445 .push(finding("/repo/a.ts"));
1446 results
1447 .deprecated_exports_in_use
1448 .push(finding("/repo/b.ts"));
1449
1450 let mut changed = FxHashSet::default();
1451 changed.insert(PathBuf::from("/repo/a.ts"));
1452 changed.insert(PathBuf::from("/repo/consumer.ts"));
1453 filter_results_by_changed_files(&mut results, &changed);
1454
1455 let kept: Vec<_> = results
1456 .deprecated_exports_in_use
1457 .iter()
1458 .map(|f| f.export.path.clone())
1459 .collect();
1460 assert_eq!(kept, vec![PathBuf::from("/repo/a.ts")]);
1461 }
1462
1463 #[test]
1464 fn filter_results_keeps_relative_manifest_finding_when_manifest_changed() {
1465 let mut results = AnalysisResults::default();
1466 results
1467 .empty_catalog_groups
1468 .push(EmptyCatalogGroupFinding::with_actions(EmptyCatalogGroup {
1469 catalog_name: "legacy".to_owned(),
1470 path: PathBuf::from("pnpm-workspace.yaml"),
1471 line: 4,
1472 }));
1473
1474 let mut changed = FxHashSet::default();
1475 changed.insert(PathBuf::from("/repo/pnpm-workspace.yaml"));
1476
1477 filter_results_by_changed_files(&mut results, &changed);
1478
1479 assert_eq!(results.empty_catalog_groups.len(), 1);
1480 }
1481
1482 #[test]
1483 fn filter_duplication_keeps_groups_with_changed_instances_and_recomputes_stats() {
1484 let mut report = DuplicationReport {
1485 clone_groups: vec![
1486 CloneGroup {
1487 instances: vec![
1488 CloneInstance {
1489 is_symlink: false,
1490 file: PathBuf::from("/repo/a.ts"),
1491 start_line: 1,
1492 end_line: 5,
1493 start_col: 0,
1494 end_col: 10,
1495 fragment: "code".to_owned(),
1496 },
1497 CloneInstance {
1498 is_symlink: false,
1499 file: PathBuf::from("/repo/b.ts"),
1500 start_line: 1,
1501 end_line: 5,
1502 start_col: 0,
1503 end_col: 10,
1504 fragment: "code".to_owned(),
1505 },
1506 ],
1507 token_count: 20,
1508 line_count: 5,
1509 similarity: None,
1510 },
1511 CloneGroup {
1512 instances: vec![
1513 CloneInstance {
1514 is_symlink: false,
1515 file: PathBuf::from("/repo/c.ts"),
1516 start_line: 1,
1517 end_line: 5,
1518 start_col: 0,
1519 end_col: 10,
1520 fragment: "other".to_owned(),
1521 },
1522 CloneInstance {
1523 is_symlink: false,
1524 file: PathBuf::from("/repo/d.ts"),
1525 start_line: 1,
1526 end_line: 5,
1527 start_col: 0,
1528 end_col: 10,
1529 fragment: "other".to_owned(),
1530 },
1531 ],
1532 token_count: 20,
1533 line_count: 5,
1534 similarity: None,
1535 },
1536 ],
1537 clone_families: Vec::new(),
1538 mirrored_directories: Vec::new(),
1539 stats: DuplicationStats {
1540 total_files: 4,
1541 files_with_clones: 4,
1542 total_lines: 100,
1543 duplicated_lines: 20,
1544 total_tokens: 200,
1545 duplicated_tokens: 80,
1546 clone_groups: 2,
1547 clone_families: 0,
1548 clone_instances: 4,
1549 duplication_percentage: 20.0,
1550 clone_groups_below_min_occurrences: 0,
1551 clone_groups_ignored: 0,
1552 near_candidates_skipped: 0,
1553 },
1554 };
1555
1556 let mut changed = FxHashSet::default();
1557 changed.insert(PathBuf::from("/repo/a.ts"));
1558
1559 filter_duplication_by_changed_files(&mut report, &changed, Path::new("/repo"));
1560
1561 assert_eq!(report.clone_groups.len(), 1);
1562 assert_eq!(report.stats.clone_groups, 1);
1563 assert_eq!(report.stats.clone_instances, 2);
1564 }
1565
1566 #[test]
1570 fn changed_file_scope_orders_families_by_file_set() {
1571 let group = |files: [&str; 2], line_count: usize| CloneGroup {
1572 instances: files
1573 .iter()
1574 .map(|file| CloneInstance {
1575 is_symlink: false,
1576 file: PathBuf::from(file),
1577 start_line: 1,
1578 end_line: line_count,
1579 start_col: 0,
1580 end_col: 10,
1581 fragment: String::new(),
1582 })
1583 .collect(),
1584 token_count: line_count * 10,
1585 line_count,
1586 similarity: None,
1587 };
1588 let mut report = DuplicationReport {
1589 clone_groups: vec![
1590 group(["/repo/y.ts", "/repo/z.ts"], 46),
1591 group(["/repo/a.ts", "/repo/b.ts"], 7),
1592 ],
1593 clone_families: Vec::new(),
1594 mirrored_directories: Vec::new(),
1595 stats: DuplicationStats::default(),
1596 };
1597 let changed: FxHashSet<PathBuf> = ["/repo/a.ts", "/repo/y.ts"]
1598 .into_iter()
1599 .map(PathBuf::from)
1600 .collect();
1601
1602 filter_duplication_by_changed_files(&mut report, &changed, Path::new("/repo"));
1603
1604 let order: Vec<&PathBuf> = report
1605 .clone_families
1606 .iter()
1607 .map(|family| &family.files[0])
1608 .collect();
1609 assert_eq!(
1610 order,
1611 [&PathBuf::from("/repo/a.ts"), &PathBuf::from("/repo/y.ts")]
1612 );
1613 }
1614
1615 #[test]
1618 fn one_probe_resolves_the_common_dir_and_the_toplevel() {
1619 let repo = tempfile::tempdir().expect("tempdir");
1620 run_git(repo.path(), &["init", "--quiet"]);
1621 let nested = repo.path().join("packages/app");
1622 std::fs::create_dir_all(&nested).expect("nested dir");
1623 for cwd in [repo.path(), nested.as_path()] {
1624 let (common, toplevel) =
1625 resolve_git_common_dir_and_toplevel(cwd).expect("combined probe");
1626 assert_eq!(common, resolve_git_common_dir(cwd).expect("common dir"));
1627 assert_eq!(toplevel, resolve_git_toplevel(cwd).expect("toplevel"));
1628 }
1629 }
1630
1631 #[test]
1634 fn the_combined_probe_fails_without_a_work_tree() {
1635 let repo = tempfile::tempdir().expect("tempdir");
1636 run_git(repo.path(), &["init", "--quiet", "--bare"]);
1637 assert!(resolve_git_common_dir_and_toplevel(repo.path()).is_err());
1638 assert!(resolve_git_common_dir(repo.path()).is_ok());
1639 }
1640}