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