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;
10pub type EditorCloneGroup = fallow_types::duplicates::CloneGroup;
11pub type EditorCloneInstance = fallow_types::duplicates::CloneInstance;
12pub type EditorDuplicationReport = fallow_types::duplicates::DuplicationReport;
13pub type EditorDuplicationStats = fallow_types::duplicates::DuplicationStats;
14pub type EditorMirroredDirectory = fallow_types::duplicates::MirroredDirectory;
15pub type EditorRefactoringKind = fallow_types::duplicates::RefactoringKind;
16pub type EditorRefactoringSuggestion = fallow_types::duplicates::RefactoringSuggestion;
17
18#[derive(Debug, Clone)]
20pub struct EditorCloneFingerprintSet {
21 inner: fallow_engine::duplicates::CloneFingerprintSet,
22}
23
24impl EditorCloneFingerprintSet {
25 #[must_use]
27 pub fn from_groups(groups: &[EditorCloneGroup]) -> Self {
28 Self {
29 inner: fallow_engine::duplicates::CloneFingerprintSet::from_groups(groups),
30 }
31 }
32
33 #[must_use]
35 pub fn fingerprint_for_group(&self, group: &EditorCloneGroup) -> String {
36 self.inner.fingerprint_for_group(group)
37 }
38
39 #[must_use]
41 pub fn fingerprint_for_parts(
42 &self,
43 instances: &[EditorCloneInstance],
44 token_count: usize,
45 line_count: usize,
46 ) -> String {
47 self.inner
48 .fingerprint_for_parts(instances, token_count, line_count)
49 }
50
51 #[must_use]
53 pub fn find_group<'a>(
54 &self,
55 groups: &'a [EditorCloneGroup],
56 fingerprint: &str,
57 ) -> Option<&'a EditorCloneGroup> {
58 self.inner.find_group(groups, fingerprint)
59 }
60}
61
62pub mod editor_duplicates {
63 pub use crate::editor::{
64 EditorCloneFamily as CloneFamily, EditorCloneFingerprintSet as CloneFingerprintSet,
65 EditorCloneGroup as CloneGroup, EditorCloneInstance as CloneInstance,
66 EditorDuplicationReport as DuplicationReport, EditorDuplicationStats as DuplicationStats,
67 EditorMirroredDirectory as MirroredDirectory, EditorRefactoringKind as RefactoringKind,
68 EditorRefactoringSuggestion as RefactoringSuggestion,
69 };
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum ChangedFilesError {
75 InvalidRef(String),
77 GitMissing(String),
79 NotARepository,
81 GitFailed(String),
83}
84
85impl ChangedFilesError {
86 #[must_use]
88 pub fn describe(&self) -> String {
89 match self {
90 Self::InvalidRef(err) => format!("invalid git ref: {err}"),
91 Self::GitMissing(err) => format!("failed to run git: {err}"),
92 Self::NotARepository => "not a git repository".to_owned(),
93 Self::GitFailed(stderr) => {
94 let lower = stderr.to_ascii_lowercase();
95 if lower.contains("not a valid object name")
96 || lower.contains("unknown revision")
97 || lower.contains("ambiguous argument")
98 {
99 format!(
100 "{stderr} (shallow clone? try `git fetch --unshallow`, or set `fetch-depth: 0` on actions/checkout / `GIT_DEPTH: 0` in GitLab CI)"
101 )
102 } else {
103 stderr.clone()
104 }
105 }
106 }
107 }
108}
109
110impl From<fallow_engine::changed_files::ChangedFilesError> for ChangedFilesError {
111 fn from(error: fallow_engine::changed_files::ChangedFilesError) -> Self {
112 match error {
113 fallow_engine::changed_files::ChangedFilesError::InvalidRef(err) => {
114 Self::InvalidRef(err)
115 }
116 fallow_engine::changed_files::ChangedFilesError::GitMissing(err) => {
117 Self::GitMissing(err)
118 }
119 fallow_engine::changed_files::ChangedFilesError::NotARepository => Self::NotARepository,
120 fallow_engine::changed_files::ChangedFilesError::GitFailed(stderr) => {
121 Self::GitFailed(stderr)
122 }
123 }
124 }
125}
126
127pub fn resolve_git_toplevel(cwd: &Path) -> Result<PathBuf, ChangedFilesError> {
134 fallow_engine::changed_files::resolve_git_toplevel(cwd).map_err(ChangedFilesError::from)
135}
136
137pub fn try_get_changed_files_with_toplevel(
144 cwd: &Path,
145 toplevel: &Path,
146 git_ref: &str,
147) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
148 fallow_engine::changed_files::try_get_changed_files_with_toplevel(cwd, toplevel, git_ref)
149 .map_err(ChangedFilesError::from)
150}
151
152pub mod editor_extract {
153 pub use fallow_types::extract::{
154 AngularComponentSelector, AngularInputMember, AngularOutputMember,
155 AngularTemplateMemberAccessFact, AngularThisSpreadFact, CalleeUse, ClassHeritageInfo,
156 ComplexityContribution, ComplexityContributionKind, ComplexityMetric, ComponentEmit,
157 ComponentFunction, ComponentFunctionKind, ComponentProp, CssAnalytics, CssDeclarationBlock,
158 CssRuleMetric, DiFramework, DiKeySite, DiRole, DispatchedEvent,
159 DynamicCustomElementRenderFact, DynamicImportInfo, DynamicImportPattern, ExportInfo,
160 ExportName, FactoryCallMemberAccessFact, FactoryFnMemberAccessFact, FactoryReturnExport,
161 FlagUse, FlagUseKind, FluentChainMemberAccessFact, FluentChainNewMemberAccessFact,
162 ForwardAttr, FunctionComplexity, HookUse, HookUseKind, ImportInfo, ImportedName,
163 InstanceExportBindingFact, LoadReturnKey, LocalTypeDeclaration, MemberAccess, MemberInfo,
164 MemberKind, MisplacedDirectiveSite, ModuleInfo, NamespaceObjectAlias, PUBLIC_ENV_EXACT,
165 PUBLIC_ENV_METADATA_TOKENS, PUBLIC_ENV_PREFIXES, ParseResult, PlaywrightFixtureAliasFact,
166 PlaywrightFixtureDefinitionFact, PlaywrightFixtureTypeFact, PlaywrightFixtureUseFact,
167 PublicSignatureTypeReference, ReExportInfo, RegisteredCustomElement, RenderEdge,
168 RequireCallInfo, SECRET_ENV_TOKENS, SanitizedSinkArg, SanitizerScope, SecurityControlKind,
169 SecurityControlSite, SecurityUrlShape, SemanticFact, SemanticFactView, SinkArgKind,
170 SinkLiteralValue, SinkObjectProperty, SinkShape, SinkSite,
171 SkippedSecurityCalleeExpressionKind, SkippedSecurityCalleeReason,
172 SkippedSecurityCalleeSite, TaintedBinding, VisibilityTag,
173 };
174}
175
176pub mod editor_results {
177 pub use fallow_types::output_dead_code::{
178 BoundaryCallViolationFinding, BoundaryCoverageViolationFinding, BoundaryViolationFinding,
179 CircularDependencyFinding, DevDependencyInProductionFinding, DuplicateExportFinding,
180 DuplicatePropShapeFinding, DynamicSegmentNameConflictFinding, EmptyCatalogGroupFinding,
181 InvalidClientExportFinding, MisconfiguredDependencyOverrideFinding,
182 MisplacedDirectiveFinding, MixedClientServerBarrelFinding, PolicyViolationFinding,
183 PrivateTypeLeakFinding, PropDrillingChainFinding, ReExportCycleFinding,
184 RouteCollisionFinding, TestOnlyDependencyFinding, ThinWrapperFinding,
185 TypeOnlyDependencyFinding, UnlistedDependencyFinding, UnprovidedInjectFinding,
186 UnrenderedComponentFinding, UnresolvedCatalogReferenceFinding, UnresolvedImportFinding,
187 UnusedCatalogEntryFinding, UnusedClassMemberFinding, UnusedComponentEmitFinding,
188 UnusedComponentInputFinding, UnusedComponentOutputFinding, UnusedComponentPropFinding,
189 UnusedDependencyFinding, UnusedDependencyOverrideFinding, UnusedDevDependencyFinding,
190 UnusedEnumMemberFinding, UnusedExportFinding, UnusedFileFinding, UnusedLoadDataKeyFinding,
191 UnusedOptionalDependencyFinding, UnusedServerActionFinding, UnusedStoreMemberFinding,
192 UnusedSvelteEventFinding, UnusedTypeFinding,
193 };
194 pub use fallow_types::results::{
195 ActiveSuppression, AnalysisResults, BoundaryCallViolation, BoundaryCoverageViolation,
196 BoundaryViolation, CircularDependency, CircularDependencyEdge, DependencyLocation,
197 DependencyOverrideMisconfigReason, DependencyOverrideSource, DevDependencyInProduction,
198 DuplicateExport, DuplicateLocation, DuplicatePropShape, DuplicatePropShapeMember,
199 DynamicSegmentNameConflict, EmptyCatalogGroup, EntryPointSummary, ExportUsage, FeatureFlag,
200 FlagConfidence, FlagKind, ImportSite, InvalidClientExport, MisconfiguredDependencyOverride,
201 MisplacedDirective, MixedClientServerBarrel, PolicyRuleKind, PolicyViolation,
202 PolicyViolationSeverity, PrivateTypeLeak, PropDrillHop, PropDrillingChain, ReExportCycle,
203 ReExportCycleKind, ReactComponentIntel, ReactHookSummary, ReactPropDrill, ReactPropIntel,
204 ReferenceLocation, RenderFanInComponent, RenderFanInMetric, RouteCollision,
205 SecurityAttackSurfaceEntry, SecurityCandidate, SecurityCandidateBoundary,
206 SecurityCandidateSink, SecurityDeadCodeContext, SecurityDeadCodeKind,
207 SecurityDefensiveBoundary, SecurityDefensiveControl, SecurityFinding, SecurityFindingKind,
208 SecurityNetworkContext, SecurityReachability, SecurityRuntimeContext, SecurityRuntimeState,
209 SecuritySeverity, SecurityTaintFlow, SecurityUnresolvedCalleeDiagnostic,
210 SecurityZoneCrossing, StaleSuppression, SuppressionOrigin, TaintConfidence, TaintEndpoint,
211 TaintPath, TestOnlyDependency, ThinWrapper, TraceHop, TraceHopRole, TypeOnlyDependency,
212 UnlistedDependency, UnprovidedInject, UnrenderedComponent, UnresolvedCatalogReference,
213 UnresolvedImport, UnusedCatalogEntry, UnusedComponentEmit, UnusedComponentInput,
214 UnusedComponentOutput, UnusedComponentProp, UnusedDependency, UnusedDependencyOverride,
215 UnusedExport, UnusedFile, UnusedLoadDataKey, UnusedMember, UnusedServerAction,
216 UnusedSvelteEvent,
217 };
218}
219
220pub mod editor_security {
221 #[must_use]
223 pub fn security_catalogue_title(kind: &str) -> Option<&'static str> {
224 fallow_engine::dead_code::security_catalogue_title(kind)
225 }
226}
227
228pub mod editor_suppress {
229 pub use fallow_types::suppress::{IssueKind, is_suppressed};
230}
231
232pub type EditorAnalysisResults = fallow_types::results::AnalysisResults;
233
234#[derive(Debug)]
239pub struct EditorDeadCodeAnalysisOutput {
240 pub results: EditorAnalysisResults,
241 pub modules: Option<Vec<ModuleInfo>>,
242 pub files: Option<Vec<DiscoveredFile>>,
243}
244
245impl EditorDeadCodeAnalysisOutput {
246 fn from_engine(output: fallow_engine::dead_code::DeadCodeAnalysisOutput) -> Self {
247 Self {
248 results: output.results,
249 modules: output.modules,
250 files: output.files,
251 }
252 }
253}
254
255#[derive(Debug, Clone, PartialEq, Eq)]
261pub struct EditorInlineComplexityFinding {
262 pub path: PathBuf,
263 pub name: String,
264 pub line: u32,
265 pub col: u32,
266 pub cyclomatic: u16,
267 pub cognitive: u16,
268 pub exceeded: EditorInlineComplexityExceeded,
269}
270
271#[derive(Debug, Clone, Copy, PartialEq, Eq)]
273pub enum EditorInlineComplexityExceeded {
274 Cyclomatic,
275 Cognitive,
276 CyclomaticAndCognitive,
277}
278
279#[must_use]
281pub fn collect_inline_complexity(
282 config: &fallow_config::ResolvedConfig,
283 output: &EditorDeadCodeAnalysisOutput,
284) -> Vec<EditorInlineComplexityFinding> {
285 let Some(modules) = output.modules.as_ref() else {
286 return Vec::new();
287 };
288 let Some(files) = output.files.as_ref() else {
289 return Vec::new();
290 };
291
292 let file_paths: rustc_hash::FxHashMap<_, _> =
293 files.iter().map(|file| (file.id, &file.path)).collect();
294 let ignore_set = build_health_ignore_set(&config.health.ignore);
295 let mut findings = Vec::new();
296
297 for module in modules {
298 let Some(path) = file_paths.get(&module.file_id) else {
299 continue;
300 };
301 let relative = path.strip_prefix(&config.root).unwrap_or(path);
302 if ignore_set
303 .as_ref()
304 .is_some_and(|set| set.is_match(relative))
305 {
306 continue;
307 }
308
309 for function in &module.complexity {
310 if fallow_types::suppress::is_suppressed(
311 &module.suppressions,
312 function.line,
313 fallow_types::suppress::IssueKind::Complexity,
314 ) {
315 continue;
316 }
317
318 let exceeds_cyclomatic = function.cyclomatic > config.health.max_cyclomatic;
319 let exceeds_cognitive = function.cognitive > config.health.max_cognitive;
320 let exceeded = match (exceeds_cyclomatic, exceeds_cognitive) {
321 (true, true) => EditorInlineComplexityExceeded::CyclomaticAndCognitive,
322 (true, false) => EditorInlineComplexityExceeded::Cyclomatic,
323 (false, true) => EditorInlineComplexityExceeded::Cognitive,
324 (false, false) => continue,
325 };
326
327 findings.push(EditorInlineComplexityFinding {
328 path: (*path).clone(),
329 name: function.name.clone(),
330 line: function.line,
331 col: function.col,
332 cyclomatic: function.cyclomatic,
333 cognitive: function.cognitive,
334 exceeded,
335 });
336 }
337 }
338
339 findings
340}
341
342#[allow(
344 clippy::implicit_hasher,
345 reason = "editor analysis changed-file sets use the workspace FxHashSet convention"
346)]
347pub fn filter_inline_complexity_by_changed_files(
348 findings: &mut Vec<EditorInlineComplexityFinding>,
349 changed_files: &FxHashSet<PathBuf>,
350) {
351 findings.retain(|finding| changed_files.contains(&finding.path));
352}
353
354fn build_health_ignore_set(patterns: &[String]) -> Option<globset::GlobSet> {
355 if patterns.is_empty() {
356 return None;
357 }
358
359 let mut builder = globset::GlobSetBuilder::new();
360 for pattern in patterns {
361 let Ok(glob) = globset::Glob::new(pattern) else {
362 continue;
363 };
364 builder.add(glob);
365 }
366 builder.build().ok()
367}
368
369#[derive(Debug)]
371pub struct EditorAnalysisSession {
372 inner: fallow_engine::session::AnalysisSession,
373}
374
375impl EditorAnalysisSession {
376 pub fn load(root: &Path, config_path: Option<&Path>) -> fallow_engine::EngineResult<Self> {
382 fallow_engine::session::AnalysisSession::load(root, config_path).map(Self::from_engine)
383 }
384
385 pub fn load_with_config(
391 root: &Path,
392 config_path: Option<&Path>,
393 configure: impl FnOnce(&mut fallow_config::ResolvedConfig),
394 ) -> fallow_engine::EngineResult<Self> {
395 fallow_engine::session::AnalysisSession::load_with_config(root, config_path, configure)
396 .map(Self::from_engine)
397 }
398
399 pub fn load_with_config_options(
406 root: &Path,
407 config_path: Option<&Path>,
408 load_options: fallow_config::ConfigLoadOptions,
409 configure: impl FnOnce(&mut fallow_config::ResolvedConfig),
410 ) -> fallow_engine::EngineResult<Self> {
411 fallow_engine::session::AnalysisSession::load_with_config_options(
412 root,
413 config_path,
414 load_options,
415 configure,
416 )
417 .map(Self::from_engine)
418 }
419
420 #[must_use]
422 pub fn load_default(root: &Path) -> Self {
423 Self::from_engine(fallow_engine::session::AnalysisSession::load_default(root))
424 }
425
426 #[must_use]
428 pub fn config(&self) -> &fallow_config::ResolvedConfig {
429 self.inner.config()
430 }
431
432 #[must_use]
434 pub fn config_path(&self) -> Option<&Path> {
435 self.inner.config_path()
436 }
437
438 pub fn analyze_project_with(
444 &self,
445 duplicates_config: &fallow_config::DuplicatesConfig,
446 retain_complexity_artifacts: bool,
447 ) -> fallow_engine::EngineResult<EditorProjectAnalysisOutput> {
448 self.inner
449 .analyze_project_with(duplicates_config, retain_complexity_artifacts)
450 .map(EditorProjectAnalysisOutput::from_engine)
451 }
452
453 pub fn analyze_project_with_changed_files(
463 &self,
464 duplicates_config: &fallow_config::DuplicatesConfig,
465 retain_complexity_artifacts: bool,
466 changed_files: Option<&FxHashSet<PathBuf>>,
467 ) -> fallow_engine::EngineResult<EditorProjectAnalysisOutput> {
468 self.inner
469 .analyze_project_with_artifacts(
470 duplicates_config,
471 fallow_engine::project_analysis::ProjectAnalysisArtifactOptions {
472 retain_complexity_artifacts,
473 changed_files: changed_files.cloned(),
474 ..fallow_engine::project_analysis::ProjectAnalysisArtifactOptions::default()
475 },
476 )
477 .map(fallow_engine::project_analysis::ProjectAnalysisArtifacts::into_output)
478 .map(EditorProjectAnalysisOutput::from_engine)
479 }
480
481 const fn from_engine(inner: fallow_engine::session::AnalysisSession) -> Self {
482 Self { inner }
483 }
484}
485
486#[derive(Debug)]
488pub struct EditorProjectAnalysisOutput {
489 pub dead_code: EditorDeadCodeAnalysisOutput,
490 pub duplication: EditorDuplicationReport,
491}
492
493impl EditorProjectAnalysisOutput {
494 fn from_engine(output: fallow_engine::project_analysis::ProjectAnalysisOutput) -> Self {
495 Self {
496 dead_code: EditorDeadCodeAnalysisOutput::from_engine(output.dead_code),
497 duplication: output.duplication,
498 }
499 }
500}
501
502#[derive(Debug, Default)]
504pub struct EditorAnalysisOutput {
505 pub results: EditorAnalysisResults,
506 pub duplication: EditorDuplicationReport,
507}
508
509impl EditorAnalysisOutput {
510 #[must_use]
511 pub const fn new(results: EditorAnalysisResults, duplication: EditorDuplicationReport) -> Self {
512 Self {
513 results,
514 duplication,
515 }
516 }
517
518 #[must_use]
519 pub fn from_project_output(output: EditorProjectAnalysisOutput) -> Self {
520 Self::new(output.dead_code.results, output.duplication)
521 }
522
523 pub fn merge_project_output(&mut self, output: EditorProjectAnalysisOutput) {
524 self.merge_results(output.dead_code.results);
525 self.merge_duplication(output.duplication);
526 }
527
528 pub fn merge_results(&mut self, source: EditorAnalysisResults) {
529 self.results.merge_into(source);
530 }
531
532 pub fn merge_duplication(&mut self, source: EditorDuplicationReport) {
533 self.duplication.clone_groups.extend(source.clone_groups);
534 self.duplication
535 .clone_families
536 .extend(source.clone_families);
537 self.duplication
538 .mirrored_directories
539 .extend(source.mirrored_directories);
540 self.duplication.stats.clone_groups += source.stats.clone_groups;
541 self.duplication.stats.clone_instances += source.stats.clone_instances;
542 self.duplication.stats.total_files += source.stats.total_files;
543 self.duplication.stats.files_with_clones += source.stats.files_with_clones;
544 self.duplication.stats.total_lines += source.stats.total_lines;
545 self.duplication.stats.duplicated_lines += source.stats.duplicated_lines;
546 self.duplication.stats.total_tokens += source.stats.total_tokens;
547 self.duplication.stats.duplicated_tokens += source.stats.duplicated_tokens;
548 self.duplication.stats.clone_groups_below_min_occurrences +=
549 source.stats.clone_groups_below_min_occurrences;
550 self.duplication.stats.duplication_percentage = if self.duplication.stats.total_lines > 0 {
551 (self.duplication.stats.duplicated_lines as f64
552 / self.duplication.stats.total_lines as f64)
553 * 100.0
554 } else {
555 0.0
556 };
557 }
558
559 pub fn filter_by_changed_files(&mut self, changed_files: &FxHashSet<PathBuf>, root: &Path) {
560 fallow_engine::changed_files::filter_results_by_changed_files(
561 &mut self.results,
562 changed_files,
563 );
564 fallow_engine::changed_files::filter_duplication_by_changed_files(
565 &mut self.duplication,
566 changed_files,
567 root,
568 );
569 }
570
571 pub fn filter_by_changed_since(
572 &mut self,
573 root: &Path,
574 toplevel: &Path,
575 git_ref: &str,
576 ) -> Result<usize, ChangedFilesError> {
577 let changed = try_get_changed_files_with_toplevel(root, toplevel, git_ref)?;
578 let changed_count = changed.len();
579 self.filter_by_changed_files(&changed, root);
580 Ok(changed_count)
581 }
582}
583
584#[cfg(test)]
585mod tests {
586 use super::*;
587
588 use fallow_types::duplicates::{CloneGroup, CloneInstance, DuplicationStats};
589
590 #[test]
591 fn merges_duplication_stats_and_recomputes_percentage() {
592 let mut output = EditorAnalysisOutput {
593 duplication: EditorDuplicationReport {
594 clone_groups: vec![CloneGroup {
595 instances: vec![CloneInstance {
596 file: PathBuf::from("src/a.ts"),
597 start_line: 1,
598 end_line: 4,
599 start_col: 0,
600 end_col: 10,
601 fragment: "const a = 1;".to_string(),
602 }],
603 token_count: 8,
604 line_count: 4,
605 }],
606 clone_families: Vec::new(),
607 mirrored_directories: Vec::new(),
608 stats: DuplicationStats {
609 clone_groups: 1,
610 clone_instances: 1,
611 total_files: 1,
612 files_with_clones: 1,
613 total_lines: 20,
614 duplicated_lines: 4,
615 total_tokens: 80,
616 duplicated_tokens: 8,
617 duplication_percentage: 20.0,
618 clone_groups_below_min_occurrences: 1,
619 },
620 },
621 ..Default::default()
622 };
623
624 output.merge_duplication(EditorDuplicationReport {
625 clone_groups: Vec::new(),
626 clone_families: Vec::new(),
627 mirrored_directories: Vec::new(),
628 stats: DuplicationStats {
629 clone_groups: 0,
630 clone_instances: 0,
631 total_files: 1,
632 files_with_clones: 0,
633 total_lines: 30,
634 duplicated_lines: 6,
635 total_tokens: 120,
636 duplicated_tokens: 12,
637 duplication_percentage: 20.0,
638 clone_groups_below_min_occurrences: 2,
639 },
640 });
641
642 assert_eq!(output.duplication.stats.total_lines, 50);
643 assert_eq!(output.duplication.stats.duplicated_lines, 10);
644 assert_eq!(
645 output.duplication.stats.clone_groups_below_min_occurrences,
646 3
647 );
648 assert!((output.duplication.stats.duplication_percentage - 20.0).abs() < f64::EPSILON);
649 }
650
651 #[test]
652 fn editor_session_returns_api_owned_project_output() {
653 let temp = tempfile::tempdir().expect("temp project");
654 let root = temp.path();
655 std::fs::create_dir_all(root.join("src")).expect("src dir");
656 std::fs::write(
657 root.join("package.json"),
658 r#"{"name":"editor-api-session","main":"src/index.ts"}"#,
659 )
660 .expect("package.json");
661 std::fs::write(
662 root.join("src/index.ts"),
663 "export const used = 1;\nconsole.log(used);\n",
664 )
665 .expect("source");
666
667 let session = EditorAnalysisSession::load(root, None).expect("session loads");
668 let output = session
669 .analyze_project_with(&fallow_config::DuplicatesConfig::default(), true)
670 .expect("analysis runs");
671
672 assert!(output.dead_code.modules.is_some());
673 assert!(
674 output
675 .dead_code
676 .files
677 .as_ref()
678 .is_some_and(|files| !files.is_empty())
679 );
680 }
681
682 #[test]
683 fn editor_session_scopes_duplication_to_changed_files() {
684 let temp = tempfile::tempdir().expect("temp project");
685 let root = temp.path();
686 let src = root.join("src");
687 std::fs::create_dir_all(&src).expect("src dir");
688 std::fs::write(
689 root.join("package.json"),
690 r#"{"name":"editor-api-session","main":"src/a.ts"}"#,
691 )
692 .expect("package.json");
693 let repeated =
694 "export function repeated() {\n return ['alpha', 'beta', 'gamma'].join(',');\n}\n";
695 std::fs::write(src.join("a.ts"), repeated).expect("source a");
696 std::fs::write(src.join("b.ts"), repeated).expect("source b");
697
698 let session = EditorAnalysisSession::load(root, None).expect("session loads");
699 let mut config = session.config().duplicates.clone();
700 config.min_tokens = 1;
701 config.min_lines = 1;
702 let full = session
703 .analyze_project_with(&config, false)
704 .expect("analysis runs");
705 assert!(!full.duplication.clone_groups.is_empty());
706
707 let mut changed_files = FxHashSet::default();
708 changed_files.insert(src.join("unrelated.ts"));
709 let scoped = session
710 .analyze_project_with_changed_files(&config, false, Some(&changed_files))
711 .expect("analysis runs");
712 assert!(scoped.duplication.clone_groups.is_empty());
713 }
714
715 #[test]
716 fn build_health_ignore_set_returns_none_for_empty_patterns() {
717 assert!(
718 build_health_ignore_set(&[]).is_none(),
719 "empty ignore pattern list should avoid building a matcher"
720 );
721 }
722
723 #[test]
724 fn build_health_ignore_set_matches_glob_patterns() {
725 let set =
726 build_health_ignore_set(&["**/*.test.ts".to_string(), "src/generated/**".to_string()])
727 .expect("valid patterns build a glob set");
728
729 assert!(set.is_match(Path::new("src/foo.test.ts")));
730 assert!(set.is_match(Path::new("src/generated/client.ts")));
731 assert!(!set.is_match(Path::new("src/app.ts")));
732 }
733
734 #[test]
735 fn build_health_ignore_set_skips_invalid_patterns() {
736 let result = build_health_ignore_set(&["[invalid-glob".to_string()]);
737
738 match result {
739 None => {}
740 Some(set) => assert!(
741 !set.is_match(Path::new("any/path.ts")),
742 "set built from only invalid patterns must not match anything"
743 ),
744 }
745 }
746
747 fn make_inline_finding(path: PathBuf) -> EditorInlineComplexityFinding {
748 EditorInlineComplexityFinding {
749 path,
750 name: "myFn".to_string(),
751 line: 1,
752 col: 0,
753 cyclomatic: 5,
754 cognitive: 4,
755 exceeded: EditorInlineComplexityExceeded::Cyclomatic,
756 }
757 }
758
759 #[test]
760 fn filter_inline_complexity_keeps_findings_in_changed_set() {
761 let changed: FxHashSet<PathBuf> = [PathBuf::from("/src/a.ts"), PathBuf::from("/src/b.ts")]
762 .into_iter()
763 .collect();
764 let mut findings = vec![
765 make_inline_finding(PathBuf::from("/src/a.ts")),
766 make_inline_finding(PathBuf::from("/src/c.ts")),
767 ];
768
769 filter_inline_complexity_by_changed_files(&mut findings, &changed);
770
771 assert_eq!(findings.len(), 1);
772 assert_eq!(
773 findings[0].path.to_string_lossy().replace('\\', "/"),
774 "/src/a.ts"
775 );
776 }
777
778 #[test]
779 fn filter_inline_complexity_removes_all_when_changed_set_empty() {
780 let changed: FxHashSet<PathBuf> = FxHashSet::default();
781 let mut findings = vec![make_inline_finding(PathBuf::from("/src/a.ts"))];
782
783 filter_inline_complexity_by_changed_files(&mut findings, &changed);
784
785 assert!(
786 findings.is_empty(),
787 "empty changed-files set must drop all inline complexity findings"
788 );
789 }
790
791 #[test]
792 fn filter_inline_complexity_keeps_all_when_all_in_changed_set() {
793 let path_a = PathBuf::from("/src/a.ts");
794 let path_b = PathBuf::from("/src/b.ts");
795 let changed: FxHashSet<PathBuf> = [path_a.clone(), path_b.clone()].into_iter().collect();
796 let mut findings = vec![make_inline_finding(path_a), make_inline_finding(path_b)];
797
798 filter_inline_complexity_by_changed_files(&mut findings, &changed);
799
800 assert_eq!(
801 findings.len(),
802 2,
803 "all findings in the changed set must be retained"
804 );
805 }
806}