1use std::path::{Path, PathBuf};
4
5use rustc_hash::FxHashSet;
6
7use fallow_types::{discover::DiscoveredFile, extract::ModuleInfo};
8
9pub type EditorCloneFamily = fallow_types::duplicates::CloneFamily;
11pub type EditorCloneGroup = fallow_types::duplicates::CloneGroup;
13pub type EditorCloneInstance = fallow_types::duplicates::CloneInstance;
15pub type EditorDuplicationReport = fallow_types::duplicates::DuplicationReport;
17pub type EditorDuplicationStats = fallow_types::duplicates::DuplicationStats;
19pub type EditorMirroredDirectory = fallow_types::duplicates::MirroredDirectory;
21pub type EditorRefactoringKind = fallow_types::duplicates::RefactoringKind;
23pub type EditorRefactoringSuggestion = fallow_types::duplicates::RefactoringSuggestion;
25
26#[derive(Debug, Clone)]
28pub struct EditorCloneFingerprintSet {
29 inner: fallow_engine::duplicates::CloneFingerprintSet,
30}
31
32impl EditorCloneFingerprintSet {
33 #[must_use]
35 pub fn from_groups(groups: &[EditorCloneGroup]) -> Self {
36 Self {
37 inner: fallow_engine::duplicates::CloneFingerprintSet::from_groups(groups),
38 }
39 }
40
41 #[must_use]
43 pub fn fingerprint_for_group(&self, group: &EditorCloneGroup) -> String {
44 self.inner.fingerprint_for_group(group)
45 }
46
47 #[must_use]
49 pub fn fingerprint_for_parts(
50 &self,
51 instances: &[EditorCloneInstance],
52 token_count: usize,
53 line_count: usize,
54 ) -> String {
55 self.inner
56 .fingerprint_for_parts(instances, token_count, line_count)
57 }
58
59 #[must_use]
61 pub fn find_group<'a>(
62 &self,
63 groups: &'a [EditorCloneGroup],
64 fingerprint: &str,
65 ) -> Option<&'a EditorCloneGroup> {
66 self.inner.find_group(groups, fingerprint)
67 }
68}
69
70pub mod editor_duplicates {
73 pub use crate::editor::{
74 EditorCloneFamily as CloneFamily, EditorCloneFingerprintSet as CloneFingerprintSet,
75 EditorCloneGroup as CloneGroup, EditorCloneInstance as CloneInstance,
76 EditorDuplicationReport as DuplicationReport, EditorDuplicationStats as DuplicationStats,
77 EditorMirroredDirectory as MirroredDirectory, EditorRefactoringKind as RefactoringKind,
78 EditorRefactoringSuggestion as RefactoringSuggestion,
79 };
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
84pub enum ChangedFilesError {
85 InvalidRef(String),
87 GitMissing(String),
89 NotARepository,
91 GitFailed(String),
93}
94
95impl ChangedFilesError {
96 #[must_use]
98 pub fn describe(&self) -> String {
99 match self {
100 Self::InvalidRef(err) => format!("invalid git ref: {err}"),
101 Self::GitMissing(err) => format!("failed to run git: {err}"),
102 Self::NotARepository => "not a git repository".to_owned(),
103 Self::GitFailed(stderr) => {
104 let lower = stderr.to_ascii_lowercase();
105 if lower.contains("not a valid object name")
106 || lower.contains("unknown revision")
107 || lower.contains("ambiguous argument")
108 {
109 format!(
110 "{stderr} (shallow clone? try `git fetch --unshallow`, or set `fetch-depth: 0` on actions/checkout / `GIT_DEPTH: 0` in GitLab CI)"
111 )
112 } else {
113 stderr.clone()
114 }
115 }
116 }
117 }
118}
119
120impl From<fallow_engine::changed_files::ChangedFilesError> for ChangedFilesError {
121 fn from(error: fallow_engine::changed_files::ChangedFilesError) -> Self {
122 match error {
123 fallow_engine::changed_files::ChangedFilesError::InvalidRef(err) => {
124 Self::InvalidRef(err)
125 }
126 fallow_engine::changed_files::ChangedFilesError::GitMissing(err) => {
127 Self::GitMissing(err)
128 }
129 fallow_engine::changed_files::ChangedFilesError::NotARepository => Self::NotARepository,
130 fallow_engine::changed_files::ChangedFilesError::GitFailed(stderr) => {
131 Self::GitFailed(stderr)
132 }
133 }
134 }
135}
136
137pub fn resolve_git_toplevel(cwd: &Path) -> Result<PathBuf, ChangedFilesError> {
144 fallow_engine::changed_files::resolve_git_toplevel(cwd).map_err(ChangedFilesError::from)
145}
146
147pub fn try_get_changed_files_with_toplevel(
154 cwd: &Path,
155 toplevel: &Path,
156 git_ref: &str,
157) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
158 fallow_engine::changed_files::try_get_changed_files_with_toplevel(cwd, toplevel, git_ref)
159 .map_err(ChangedFilesError::from)
160}
161
162pub mod editor_extract {
165 pub use fallow_types::extract::{
166 AngularComponentSelector, AngularInputMember, AngularOutputMember,
167 AngularTemplateMemberAccessFact, AngularThisSpreadFact, CalleeUse, ClassHeritageInfo,
168 ComplexityContribution, ComplexityContributionKind, ComplexityMetric, ComponentEmit,
169 ComponentFunction, ComponentFunctionKind, ComponentProp, CssAnalytics, CssDeclarationBlock,
170 CssRuleMetric, DiFramework, DiKeySite, DiRole, DispatchedEvent,
171 DynamicCustomElementRenderFact, DynamicImportInfo, DynamicImportPattern, ExportInfo,
172 ExportName, FactoryCallMemberAccessFact, FactoryFnMemberAccessFact, FactoryReturnExport,
173 FlagUse, FlagUseKind, FluentChainMemberAccessFact, FluentChainNewMemberAccessFact,
174 ForwardAttr, FunctionComplexity, HookUse, HookUseKind, ImportInfo, ImportedName,
175 InstanceExportBindingFact, LoadReturnKey, LocalTypeDeclaration, MemberAccess, MemberInfo,
176 MemberKind, MisplacedDirectiveSite, ModuleInfo, NamespaceObjectAlias, PUBLIC_ENV_EXACT,
177 PUBLIC_ENV_METADATA_TOKENS, PUBLIC_ENV_PREFIXES, ParseResult, PlaywrightFixtureAliasFact,
178 PlaywrightFixtureDefinitionFact, PlaywrightFixtureTypeFact, PlaywrightFixtureUseFact,
179 PublicSignatureTypeReference, ReExportInfo, RegisteredCustomElement, RenderEdge,
180 RequireCallInfo, SECRET_ENV_TOKENS, SanitizedSinkArg, SanitizerScope, SecurityControlKind,
181 SecurityControlSite, SecurityUrlShape, SemanticFact, SemanticFactView, SinkArgKind,
182 SinkLiteralValue, SinkObjectProperty, SinkShape, SinkSite,
183 SkippedSecurityCalleeExpressionKind, SkippedSecurityCalleeReason,
184 SkippedSecurityCalleeSite, TaintedBinding, VisibilityTag,
185 };
186}
187
188pub mod editor_results {
191 pub use fallow_types::output_dead_code::{
192 BoundaryCallViolationFinding, BoundaryCoverageViolationFinding, BoundaryViolationFinding,
193 CircularDependencyFinding, DevDependencyInProductionFinding, DuplicateExportFinding,
194 DuplicatePropShapeFinding, DynamicSegmentNameConflictFinding, EmptyCatalogGroupFinding,
195 InvalidClientExportFinding, MisconfiguredDependencyOverrideFinding,
196 MisplacedDirectiveFinding, MixedClientServerBarrelFinding, PolicyViolationFinding,
197 PrivateTypeLeakFinding, PropDrillingChainFinding, ReExportCycleFinding,
198 RouteCollisionFinding, TestOnlyDependencyFinding, ThinWrapperFinding,
199 TypeOnlyDependencyFinding, UnlistedDependencyFinding, UnprovidedInjectFinding,
200 UnrenderedComponentFinding, UnresolvedCatalogReferenceFinding, UnresolvedImportFinding,
201 UnusedCatalogEntryFinding, UnusedClassMemberFinding, UnusedComponentEmitFinding,
202 UnusedComponentInputFinding, UnusedComponentOutputFinding, UnusedComponentPropFinding,
203 UnusedDependencyFinding, UnusedDependencyOverrideFinding, UnusedDevDependencyFinding,
204 UnusedEnumMemberFinding, UnusedExportFinding, UnusedFileFinding, UnusedLoadDataKeyFinding,
205 UnusedOptionalDependencyFinding, UnusedServerActionFinding, UnusedStoreMemberFinding,
206 UnusedSvelteEventFinding, UnusedTypeFinding,
207 };
208 pub use fallow_types::results::{
209 ActiveSuppression, AnalysisResults, BoundaryCallViolation, BoundaryCoverageViolation,
210 BoundaryViolation, CircularDependency, CircularDependencyEdge, DependencyLocation,
211 DependencyOverrideMisconfigReason, DependencyOverrideSource, DevDependencyInProduction,
212 DuplicateExport, DuplicateLocation, DuplicatePropShape, DuplicatePropShapeMember,
213 DynamicSegmentNameConflict, EmptyCatalogGroup, EntryPointSummary, ExportUsage, FeatureFlag,
214 FlagConfidence, FlagKind, ImportSite, InvalidClientExport, MisconfiguredDependencyOverride,
215 MisplacedDirective, MixedClientServerBarrel, PolicyRuleKind, PolicyViolation,
216 PolicyViolationSeverity, PrivateTypeLeak, PropDrillHop, PropDrillingChain, ReExportCycle,
217 ReExportCycleKind, ReactComponentIntel, ReactHookSummary, ReactPropDrill, ReactPropIntel,
218 ReferenceLocation, RenderFanInComponent, RenderFanInMetric, RouteCollision,
219 SecurityAttackSurfaceEntry, SecurityCandidate, SecurityCandidateBoundary,
220 SecurityCandidateSink, SecurityDeadCodeContext, SecurityDeadCodeKind,
221 SecurityDefensiveBoundary, SecurityDefensiveControl, SecurityFinding, SecurityFindingKind,
222 SecurityNetworkContext, SecurityReachability, SecurityRuntimeContext, SecurityRuntimeState,
223 SecuritySeverity, SecurityTaintFlow, SecurityUnresolvedCalleeDiagnostic,
224 SecurityZoneCrossing, StaleSuppression, SuppressionOrigin, TaintConfidence, TaintEndpoint,
225 TaintPath, TestOnlyDependency, ThinWrapper, TraceHop, TraceHopRole, TypeOnlyDependency,
226 UnlistedDependency, UnprovidedInject, UnrenderedComponent, UnresolvedCatalogReference,
227 UnresolvedImport, UnusedCatalogEntry, UnusedComponentEmit, UnusedComponentInput,
228 UnusedComponentOutput, UnusedComponentProp, UnusedDependency, UnusedDependencyOverride,
229 UnusedExport, UnusedFile, UnusedLoadDataKey, UnusedMember, UnusedServerAction,
230 UnusedSvelteEvent,
231 };
232}
233
234pub mod editor_security {
236 #[must_use]
238 pub fn security_catalogue_title(kind: &str) -> Option<&'static str> {
239 fallow_engine::dead_code::security_catalogue_title(kind)
240 }
241}
242
243pub mod editor_suppress {
245 pub use fallow_types::suppress::{IssueKind, is_suppressed};
246}
247
248pub type EditorAnalysisResults = fallow_types::results::AnalysisResults;
250
251#[derive(Debug)]
256pub struct EditorDeadCodeAnalysisOutput {
257 pub results: EditorAnalysisResults,
259 pub modules: Option<Vec<ModuleInfo>>,
262 pub files: Option<Vec<DiscoveredFile>>,
264}
265
266impl EditorDeadCodeAnalysisOutput {
267 fn from_engine(output: fallow_engine::dead_code::DeadCodeAnalysisOutput) -> Self {
268 Self {
269 results: output.results,
270 modules: output.modules,
271 files: output.files,
272 }
273 }
274}
275
276#[derive(Debug, Clone, PartialEq, Eq)]
282pub struct EditorInlineComplexityFinding {
283 pub path: PathBuf,
285 pub name: String,
287 pub line: u32,
289 pub col: u32,
291 pub cyclomatic: u16,
293 pub cognitive: u16,
295 pub exceeded: EditorInlineComplexityExceeded,
297}
298
299#[derive(Debug, Clone, Copy, PartialEq, Eq)]
301pub enum EditorInlineComplexityExceeded {
302 Cyclomatic,
304 Cognitive,
306 CyclomaticAndCognitive,
308}
309
310#[must_use]
312pub fn collect_inline_complexity(
313 config: &fallow_config::ResolvedConfig,
314 output: &EditorDeadCodeAnalysisOutput,
315) -> Vec<EditorInlineComplexityFinding> {
316 let Some(modules) = output.modules.as_ref() else {
317 return Vec::new();
318 };
319 let Some(files) = output.files.as_ref() else {
320 return Vec::new();
321 };
322
323 let file_paths: rustc_hash::FxHashMap<_, _> =
324 files.iter().map(|file| (file.id, &file.path)).collect();
325 let ignore_set = build_health_ignore_set(&config.health.ignore);
326 let mut findings = Vec::new();
327
328 for module in modules {
329 let Some(path) = file_paths.get(&module.file_id) else {
330 continue;
331 };
332 let relative = path.strip_prefix(&config.root).unwrap_or(path);
333 if ignore_set
334 .as_ref()
335 .is_some_and(|set| set.is_match(relative))
336 {
337 continue;
338 }
339
340 for function in &module.complexity {
341 if fallow_types::extract::is_synthetic_module_unit(&function.name) {
345 continue;
346 }
347 if fallow_types::suppress::is_suppressed(
348 &module.suppressions,
349 function.line,
350 fallow_types::suppress::IssueKind::Complexity,
351 ) {
352 continue;
353 }
354
355 let exceeds_cyclomatic = function.cyclomatic > config.health.max_cyclomatic;
356 let exceeds_cognitive = function.cognitive > config.health.max_cognitive;
357 let exceeded = match (exceeds_cyclomatic, exceeds_cognitive) {
358 (true, true) => EditorInlineComplexityExceeded::CyclomaticAndCognitive,
359 (true, false) => EditorInlineComplexityExceeded::Cyclomatic,
360 (false, true) => EditorInlineComplexityExceeded::Cognitive,
361 (false, false) => continue,
362 };
363
364 findings.push(EditorInlineComplexityFinding {
365 path: (*path).clone(),
366 name: function.name.clone(),
367 line: function.line,
368 col: function.col,
369 cyclomatic: function.cyclomatic,
370 cognitive: function.cognitive,
371 exceeded,
372 });
373 }
374 }
375
376 findings
377}
378
379#[allow(
381 clippy::implicit_hasher,
382 reason = "editor analysis changed-file sets use the workspace FxHashSet convention"
383)]
384pub fn filter_inline_complexity_by_changed_files(
385 findings: &mut Vec<EditorInlineComplexityFinding>,
386 changed_files: &FxHashSet<PathBuf>,
387) {
388 findings.retain(|finding| changed_files.contains(&finding.path));
389}
390
391fn build_health_ignore_set(patterns: &[String]) -> Option<globset::GlobSet> {
392 if patterns.is_empty() {
393 return None;
394 }
395
396 let mut builder = globset::GlobSetBuilder::new();
397 for pattern in patterns {
398 let Ok(glob) = globset::Glob::new(pattern) else {
399 continue;
400 };
401 builder.add(glob);
402 }
403 builder.build().ok()
404}
405
406#[derive(Debug)]
408pub struct EditorAnalysisSession {
409 inner: fallow_engine::session::AnalysisSession,
410}
411
412impl EditorAnalysisSession {
413 pub fn load(root: &Path, config_path: Option<&Path>) -> fallow_engine::EngineResult<Self> {
419 fallow_engine::session::AnalysisSession::load(root, config_path).map(Self::from_engine)
420 }
421
422 pub fn load_with_config(
428 root: &Path,
429 config_path: Option<&Path>,
430 configure: impl FnOnce(&mut fallow_config::ResolvedConfig),
431 ) -> fallow_engine::EngineResult<Self> {
432 fallow_engine::session::AnalysisSession::load_with_config(root, config_path, configure)
433 .map(Self::from_engine)
434 }
435
436 pub fn load_with_config_options(
443 root: &Path,
444 config_path: Option<&Path>,
445 load_options: fallow_config::ConfigLoadOptions,
446 configure: impl FnOnce(&mut fallow_config::ResolvedConfig),
447 ) -> fallow_engine::EngineResult<Self> {
448 fallow_engine::session::AnalysisSession::load_with_config_options(
449 root,
450 config_path,
451 load_options,
452 configure,
453 )
454 .map(Self::from_engine)
455 }
456
457 #[must_use]
459 pub fn load_default(root: &Path) -> Self {
460 Self::from_engine(fallow_engine::session::AnalysisSession::load_default(root))
461 }
462
463 #[must_use]
465 pub fn config(&self) -> &fallow_config::ResolvedConfig {
466 self.inner.config()
467 }
468
469 #[must_use]
471 pub fn config_path(&self) -> Option<&Path> {
472 self.inner.config_path()
473 }
474
475 pub fn refine_type_aware_dead_code(
483 &self,
484 options: &crate::TypeAwareOptions,
485 filters: &crate::DeadCodeFilters,
486 output: &mut EditorDeadCodeAnalysisOutput,
487 ) -> Result<Option<fallow_types::envelope::TypeAwareMeta>, crate::ProgrammaticError> {
488 let meta = crate::type_aware::refine_programmatic_dead_code(
489 options,
490 filters,
491 &self.inner,
492 &mut output.results,
493 )?;
494 fallow_engine::dead_code::apply_rule_severities(&mut output.results, self.inner.config());
498 Ok(meta)
499 }
500
501 pub fn refine_type_aware_dead_code_in_session(
503 &self,
504 semantic_session: &mut crate::TypeAwareSession,
505 changes: Option<&crate::TypeAwareFileChanges>,
506 options: &crate::TypeAwareOptions,
507 filters: &crate::DeadCodeFilters,
508 output: &mut EditorDeadCodeAnalysisOutput,
509 ) -> Result<Option<fallow_types::envelope::TypeAwareMeta>, crate::ProgrammaticError> {
510 let meta = crate::type_aware::refine_programmatic_dead_code_in_session(
511 semantic_session,
512 changes,
513 options,
514 filters,
515 &self.inner,
516 &mut output.results,
517 )?;
518 fallow_engine::dead_code::apply_rule_severities(&mut output.results, self.inner.config());
519 Ok(meta)
520 }
521
522 pub fn analyze_project_with(
528 &self,
529 duplicates_config: &fallow_config::DuplicatesConfig,
530 retain_complexity_artifacts: bool,
531 ) -> fallow_engine::EngineResult<EditorProjectAnalysisOutput> {
532 self.inner
533 .analyze_project_with(duplicates_config, retain_complexity_artifacts)
534 .map(EditorProjectAnalysisOutput::from_engine)
535 .map(|output| self.with_resolved_rule_severities(output))
536 }
537
538 pub fn analyze_project_with_changed_files(
548 &self,
549 duplicates_config: &fallow_config::DuplicatesConfig,
550 retain_complexity_artifacts: bool,
551 changed_files: Option<&FxHashSet<PathBuf>>,
552 ) -> fallow_engine::EngineResult<EditorProjectAnalysisOutput> {
553 self.inner
554 .analyze_project_with_artifacts(
555 duplicates_config,
556 fallow_engine::project_analysis::ProjectAnalysisArtifactOptions {
557 retain_complexity_artifacts,
558 changed_files: changed_files.cloned(),
559 ..fallow_engine::project_analysis::ProjectAnalysisArtifactOptions::default()
560 },
561 )
562 .map(fallow_engine::project_analysis::ProjectAnalysisArtifacts::into_output)
563 .map(EditorProjectAnalysisOutput::from_engine)
564 .map(|output| self.with_resolved_rule_severities(output))
565 }
566
567 fn with_resolved_rule_severities(
574 &self,
575 mut output: EditorProjectAnalysisOutput,
576 ) -> EditorProjectAnalysisOutput {
577 fallow_engine::dead_code::apply_rule_severities(
578 &mut output.dead_code.results,
579 self.inner.config(),
580 );
581 output
582 }
583
584 const fn from_engine(inner: fallow_engine::session::AnalysisSession) -> Self {
585 Self { inner }
586 }
587}
588
589#[derive(Debug)]
591pub struct EditorProjectAnalysisOutput {
592 pub dead_code: EditorDeadCodeAnalysisOutput,
594 pub duplication: EditorDuplicationReport,
596}
597
598impl EditorProjectAnalysisOutput {
599 fn from_engine(output: fallow_engine::project_analysis::ProjectAnalysisOutput) -> Self {
600 Self {
601 dead_code: EditorDeadCodeAnalysisOutput::from_engine(output.dead_code),
602 duplication: output.duplication,
603 }
604 }
605}
606
607#[derive(Debug, Default)]
609pub struct EditorAnalysisOutput {
610 pub results: EditorAnalysisResults,
612 pub duplication: EditorDuplicationReport,
614}
615
616impl EditorAnalysisOutput {
617 #[must_use]
619 pub const fn new(results: EditorAnalysisResults, duplication: EditorDuplicationReport) -> Self {
620 Self {
621 results,
622 duplication,
623 }
624 }
625
626 #[must_use]
628 pub fn from_project_output(output: EditorProjectAnalysisOutput) -> Self {
629 Self::new(output.dead_code.results, output.duplication)
630 }
631
632 pub fn merge_project_output(&mut self, output: EditorProjectAnalysisOutput) {
634 self.merge_results(output.dead_code.results);
635 self.merge_duplication(output.duplication);
636 }
637
638 pub fn merge_results(&mut self, source: EditorAnalysisResults) {
640 self.results.merge_into(source);
641 }
642
643 pub fn merge_duplication(&mut self, source: EditorDuplicationReport) {
646 self.duplication.clone_groups.extend(source.clone_groups);
647 self.duplication
648 .clone_families
649 .extend(source.clone_families);
650 self.duplication
651 .mirrored_directories
652 .extend(source.mirrored_directories);
653 self.duplication.stats.clone_groups += source.stats.clone_groups;
654 self.duplication.stats.clone_families += source.stats.clone_families;
655 self.duplication.stats.clone_instances += source.stats.clone_instances;
656 self.duplication.stats.total_files += source.stats.total_files;
657 self.duplication.stats.files_with_clones += source.stats.files_with_clones;
658 self.duplication.stats.total_lines += source.stats.total_lines;
659 self.duplication.stats.duplicated_lines += source.stats.duplicated_lines;
660 self.duplication.stats.total_tokens += source.stats.total_tokens;
661 self.duplication.stats.duplicated_tokens += source.stats.duplicated_tokens;
662 self.duplication.stats.clone_groups_below_min_occurrences +=
663 source.stats.clone_groups_below_min_occurrences;
664 self.duplication.stats.clone_groups_ignored += source.stats.clone_groups_ignored;
665 self.duplication.stats.near_candidates_skipped += source.stats.near_candidates_skipped;
666 self.duplication.stats.duplication_percentage = if self.duplication.stats.total_lines > 0 {
667 (self.duplication.stats.duplicated_lines as f64
668 / self.duplication.stats.total_lines as f64)
669 * 100.0
670 } else {
671 0.0
672 };
673 }
674
675 pub fn filter_by_changed_files(&mut self, changed_files: &FxHashSet<PathBuf>, root: &Path) {
677 fallow_engine::changed_files::filter_results_by_changed_files(
678 &mut self.results,
679 changed_files,
680 );
681 fallow_engine::changed_files::filter_duplication_by_changed_files(
682 &mut self.duplication,
683 changed_files,
684 root,
685 );
686 }
687
688 pub fn filter_by_changed_since(
696 &mut self,
697 root: &Path,
698 toplevel: &Path,
699 git_ref: &str,
700 ) -> Result<usize, ChangedFilesError> {
701 let changed = try_get_changed_files_with_toplevel(root, toplevel, git_ref)?;
702 let changed_count = changed.len();
703 self.filter_by_changed_files(&changed, root);
704 Ok(changed_count)
705 }
706}
707
708#[cfg(test)]
709mod tests {
710 use super::*;
711
712 use fallow_types::duplicates::{CloneFamily, CloneGroup, CloneInstance, DuplicationStats};
713
714 #[test]
715 fn merges_duplication_stats_and_recomputes_percentage() {
716 let mut output = EditorAnalysisOutput {
717 duplication: EditorDuplicationReport {
718 clone_groups: vec![CloneGroup {
719 instances: vec![CloneInstance {
720 file: PathBuf::from("src/a.ts"),
721 start_line: 1,
722 end_line: 4,
723 start_col: 0,
724 end_col: 10,
725 fragment: "const a = 1;".to_string(),
726 }],
727 token_count: 8,
728 line_count: 4,
729 similarity: None,
730 }],
731 clone_families: Vec::new(),
732 mirrored_directories: Vec::new(),
733 stats: DuplicationStats {
734 clone_groups: 1,
735 clone_families: 0,
736 clone_instances: 1,
737 total_files: 1,
738 files_with_clones: 1,
739 total_lines: 20,
740 duplicated_lines: 4,
741 total_tokens: 80,
742 duplicated_tokens: 8,
743 duplication_percentage: 20.0,
744 clone_groups_below_min_occurrences: 1,
745 clone_groups_ignored: 1,
746 near_candidates_skipped: 2,
747 },
748 },
749 ..Default::default()
750 };
751
752 output.merge_duplication(EditorDuplicationReport {
753 clone_groups: Vec::new(),
754 clone_families: Vec::new(),
755 mirrored_directories: Vec::new(),
756 stats: DuplicationStats {
757 clone_groups: 0,
758 clone_families: 0,
759 clone_instances: 0,
760 total_files: 1,
761 files_with_clones: 0,
762 total_lines: 30,
763 duplicated_lines: 6,
764 total_tokens: 120,
765 duplicated_tokens: 12,
766 duplication_percentage: 20.0,
767 clone_groups_below_min_occurrences: 2,
768 clone_groups_ignored: 3,
769 near_candidates_skipped: 4,
770 },
771 });
772
773 assert_eq!(output.duplication.stats.total_lines, 50);
774 assert_eq!(output.duplication.stats.duplicated_lines, 10);
775 assert_eq!(
776 output.duplication.stats.clone_groups_below_min_occurrences,
777 3
778 );
779 assert_eq!(output.duplication.stats.clone_groups_ignored, 4);
780 assert_eq!(output.duplication.stats.near_candidates_skipped, 6);
781 assert!((output.duplication.stats.duplication_percentage - 20.0).abs() < f64::EPSILON);
782 }
783
784 #[test]
785 fn merging_duplication_keeps_the_family_corpus_count_aligned() {
786 let family = |path: &str| CloneFamily {
787 files: vec![PathBuf::from(path)],
788 groups: Vec::new(),
789 total_duplicated_lines: 4,
790 total_duplicated_tokens: 8,
791 suggestions: Vec::new(),
792 };
793 let report = |path: &str, families: usize| EditorDuplicationReport {
794 clone_groups: Vec::new(),
795 clone_families: vec![family(path)],
796 mirrored_directories: Vec::new(),
797 stats: DuplicationStats {
798 clone_families: families,
799 ..DuplicationStats::default()
800 },
801 };
802
803 let mut output = EditorAnalysisOutput {
804 duplication: report("src/a.ts", 3),
805 ..Default::default()
806 };
807 output.merge_duplication(report("src/b.ts", 2));
808
809 assert_eq!(output.duplication.stats.clone_families, 5);
810 assert_eq!(output.duplication.clone_families_shown(), 2);
811 assert_eq!(output.duplication.clone_families_omitted(), 3);
812 assert_eq!(
813 output.duplication.clone_families_total(),
814 output.duplication.stats.clone_families
815 );
816 }
817
818 #[test]
819 fn editor_session_returns_api_owned_project_output() {
820 let temp = tempfile::tempdir().expect("temp project");
821 let root = temp.path();
822 std::fs::create_dir_all(root.join("src")).expect("src dir");
823 std::fs::write(
824 root.join("package.json"),
825 r#"{"name":"editor-api-session","main":"src/index.ts"}"#,
826 )
827 .expect("package.json");
828 std::fs::write(
829 root.join("src/index.ts"),
830 "export const used = 1;\nconsole.log(used);\n",
831 )
832 .expect("source");
833
834 let session = EditorAnalysisSession::load(root, None).expect("session loads");
835 let output = session
836 .analyze_project_with(&fallow_config::DuplicatesConfig::default(), true)
837 .expect("analysis runs");
838
839 assert!(output.dead_code.modules.is_some());
840 assert!(
841 output
842 .dead_code
843 .files
844 .as_ref()
845 .is_some_and(|files| !files.is_empty())
846 );
847 }
848
849 #[test]
850 fn editor_session_scopes_duplication_to_changed_files() {
851 let temp = tempfile::tempdir().expect("temp project");
852 let root = temp.path();
853 let src = root.join("src");
854 std::fs::create_dir_all(&src).expect("src dir");
855 std::fs::write(
856 root.join("package.json"),
857 r#"{"name":"editor-api-session","main":"src/a.ts"}"#,
858 )
859 .expect("package.json");
860 let repeated =
861 "export function repeated() {\n return ['alpha', 'beta', 'gamma'].join(',');\n}\n";
862 std::fs::write(src.join("a.ts"), repeated).expect("source a");
863 std::fs::write(src.join("b.ts"), repeated).expect("source b");
864
865 let session = EditorAnalysisSession::load(root, None).expect("session loads");
866 let mut config = session.config().duplicates.clone();
867 config.min_tokens = 1;
868 config.min_lines = 1;
869 let full = session
870 .analyze_project_with(&config, false)
871 .expect("analysis runs");
872 assert!(!full.duplication.clone_groups.is_empty());
873
874 let mut changed_files = FxHashSet::default();
875 changed_files.insert(src.join("unrelated.ts"));
876 let scoped = session
877 .analyze_project_with_changed_files(&config, false, Some(&changed_files))
878 .expect("analysis runs");
879 assert!(scoped.duplication.clone_groups.is_empty());
880 }
881
882 #[test]
883 fn build_health_ignore_set_returns_none_for_empty_patterns() {
884 assert!(
885 build_health_ignore_set(&[]).is_none(),
886 "empty ignore pattern list should avoid building a matcher"
887 );
888 }
889
890 #[test]
891 fn build_health_ignore_set_matches_glob_patterns() {
892 let set =
893 build_health_ignore_set(&["**/*.test.ts".to_string(), "src/generated/**".to_string()])
894 .expect("valid patterns build a glob set");
895
896 assert!(set.is_match(Path::new("src/foo.test.ts")));
897 assert!(set.is_match(Path::new("src/generated/client.ts")));
898 assert!(!set.is_match(Path::new("src/app.ts")));
899 }
900
901 #[test]
902 fn build_health_ignore_set_skips_invalid_patterns() {
903 let result = build_health_ignore_set(&["[invalid-glob".to_string()]);
904
905 match result {
906 None => {}
907 Some(set) => assert!(
908 !set.is_match(Path::new("any/path.ts")),
909 "set built from only invalid patterns must not match anything"
910 ),
911 }
912 }
913
914 fn make_inline_finding(path: PathBuf) -> EditorInlineComplexityFinding {
915 EditorInlineComplexityFinding {
916 path,
917 name: "myFn".to_string(),
918 line: 1,
919 col: 0,
920 cyclomatic: 5,
921 cognitive: 4,
922 exceeded: EditorInlineComplexityExceeded::Cyclomatic,
923 }
924 }
925
926 #[test]
927 fn filter_inline_complexity_keeps_findings_in_changed_set() {
928 let changed: FxHashSet<PathBuf> = [PathBuf::from("/src/a.ts"), PathBuf::from("/src/b.ts")]
929 .into_iter()
930 .collect();
931 let mut findings = vec![
932 make_inline_finding(PathBuf::from("/src/a.ts")),
933 make_inline_finding(PathBuf::from("/src/c.ts")),
934 ];
935
936 filter_inline_complexity_by_changed_files(&mut findings, &changed);
937
938 assert_eq!(findings.len(), 1);
939 assert_eq!(
940 findings[0].path.to_string_lossy().replace('\\', "/"),
941 "/src/a.ts"
942 );
943 }
944
945 #[test]
946 fn filter_inline_complexity_removes_all_when_changed_set_empty() {
947 let changed: FxHashSet<PathBuf> = FxHashSet::default();
948 let mut findings = vec![make_inline_finding(PathBuf::from("/src/a.ts"))];
949
950 filter_inline_complexity_by_changed_files(&mut findings, &changed);
951
952 assert!(
953 findings.is_empty(),
954 "empty changed-files set must drop all inline complexity findings"
955 );
956 }
957
958 #[test]
959 fn filter_inline_complexity_keeps_all_when_all_in_changed_set() {
960 let path_a = PathBuf::from("/src/a.ts");
961 let path_b = PathBuf::from("/src/b.ts");
962 let changed: FxHashSet<PathBuf> = [path_a.clone(), path_b.clone()].into_iter().collect();
963 let mut findings = vec![make_inline_finding(path_a), make_inline_finding(path_b)];
964
965 filter_inline_complexity_by_changed_files(&mut findings, &changed);
966
967 assert_eq!(
968 findings.len(),
969 2,
970 "all findings in the changed set must be retained"
971 );
972 }
973
974 #[test]
975 fn editor_session_applies_per_path_rule_overrides() {
976 let temp = tempfile::tempdir().expect("temp project");
980 let root = temp.path();
981 std::fs::create_dir_all(root.join("src/ui")).expect("ui dir");
982 std::fs::create_dir_all(root.join("src/lib")).expect("lib dir");
983 std::fs::write(
984 root.join("package.json"),
985 r#"{"name":"editor-override-rules","private":true,"main":"src/index.ts"}"#,
986 )
987 .expect("package.json");
988 std::fs::write(
989 root.join(".fallowrc.json"),
990 r#"{
991 "rules": { "unused-exports": "warn", "private-type-leaks": "warn" },
992 "overrides": [
993 {
994 "files": ["src/ui/**"],
995 "rules": { "unused-exports": "off", "private-type-leaks": "off" }
996 }
997 ]
998}"#,
999 )
1000 .expect("config");
1001 std::fs::write(
1002 root.join("src/index.ts"),
1003 "import { kitUsed } from './ui/kit';\nimport { libUsed } from './lib/util';\n\nexport const app = `${kitUsed}${libUsed}`;\n",
1004 )
1005 .expect("index");
1006 std::fs::write(
1007 root.join("src/ui/kit.ts"),
1008 "type Props = { label: string };\n\nexport const kitUsed = 'kit';\n\nexport const Unused = (props: Props) => props.label;\n",
1009 )
1010 .expect("kit");
1011 std::fs::write(
1012 root.join("src/lib/util.ts"),
1013 "type Internal = { id: string };\n\nexport const libUsed = 'lib';\n\nexport const alsoUnused = (value: Internal) => value.id;\n",
1014 )
1015 .expect("util");
1016
1017 let session = EditorAnalysisSession::load(root, None).expect("session loads");
1018 let output = session
1019 .analyze_project_with_changed_files(
1020 &fallow_config::DuplicatesConfig::default(),
1021 false,
1022 None,
1023 )
1024 .expect("analysis runs");
1025 let results = &output.dead_code.results;
1026
1027 let unused_export_paths = || {
1028 results
1029 .unused_exports
1030 .iter()
1031 .map(|finding| finding.export.path.clone())
1032 .collect::<Vec<_>>()
1033 };
1034 let leak_paths = || {
1035 results
1036 .private_type_leaks
1037 .iter()
1038 .map(|finding| finding.leak.path.clone())
1039 .collect::<Vec<_>>()
1040 };
1041
1042 assert!(
1043 !unused_export_paths()
1044 .iter()
1045 .any(|path| path.ends_with("kit.ts")),
1046 "the override turns unused-exports off for src/ui/**: {:?}",
1047 unused_export_paths()
1048 );
1049 assert!(
1050 !leak_paths().iter().any(|path| path.ends_with("kit.ts")),
1051 "the override turns private-type-leaks off for src/ui/**: {:?}",
1052 leak_paths()
1053 );
1054 assert!(
1055 unused_export_paths()
1056 .iter()
1057 .any(|path| path.ends_with("util.ts")),
1058 "paths outside the override keep their unused export: {:?}",
1059 unused_export_paths()
1060 );
1061 assert!(
1062 leak_paths().iter().any(|path| path.ends_with("util.ts")),
1063 "paths outside the override keep their private type leak: {:?}",
1064 leak_paths()
1065 );
1066 }
1067}