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, DuplicateExportFinding, DuplicatePropShapeFinding,
180 DynamicSegmentNameConflictFinding, EmptyCatalogGroupFinding, InvalidClientExportFinding,
181 MisconfiguredDependencyOverrideFinding, MisplacedDirectiveFinding,
182 MixedClientServerBarrelFinding, PolicyViolationFinding, PrivateTypeLeakFinding,
183 PropDrillingChainFinding, ReExportCycleFinding, RouteCollisionFinding,
184 TestOnlyDependencyFinding, ThinWrapperFinding, TypeOnlyDependencyFinding,
185 UnlistedDependencyFinding, UnprovidedInjectFinding, UnrenderedComponentFinding,
186 UnresolvedCatalogReferenceFinding, UnresolvedImportFinding, UnusedCatalogEntryFinding,
187 UnusedClassMemberFinding, UnusedComponentEmitFinding, UnusedComponentInputFinding,
188 UnusedComponentOutputFinding, UnusedComponentPropFinding, UnusedDependencyFinding,
189 UnusedDependencyOverrideFinding, UnusedDevDependencyFinding, UnusedEnumMemberFinding,
190 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, DuplicateExport,
198 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 #[must_use]
401 pub fn load_default(root: &Path) -> Self {
402 Self::from_engine(fallow_engine::session::AnalysisSession::load_default(root))
403 }
404
405 #[must_use]
407 pub fn config(&self) -> &fallow_config::ResolvedConfig {
408 self.inner.config()
409 }
410
411 #[must_use]
413 pub fn config_path(&self) -> Option<&Path> {
414 self.inner.config_path()
415 }
416
417 pub fn analyze_project_with(
423 &self,
424 duplicates_config: &fallow_config::DuplicatesConfig,
425 retain_complexity_artifacts: bool,
426 ) -> fallow_engine::EngineResult<EditorProjectAnalysisOutput> {
427 self.inner
428 .analyze_project_with(duplicates_config, retain_complexity_artifacts)
429 .map(EditorProjectAnalysisOutput::from_engine)
430 }
431
432 pub fn analyze_project_with_changed_files(
442 &self,
443 duplicates_config: &fallow_config::DuplicatesConfig,
444 retain_complexity_artifacts: bool,
445 changed_files: Option<&FxHashSet<PathBuf>>,
446 ) -> fallow_engine::EngineResult<EditorProjectAnalysisOutput> {
447 self.inner
448 .analyze_project_with_artifacts(
449 duplicates_config,
450 fallow_engine::project_analysis::ProjectAnalysisArtifactOptions {
451 retain_complexity_artifacts,
452 changed_files: changed_files.cloned(),
453 ..fallow_engine::project_analysis::ProjectAnalysisArtifactOptions::default()
454 },
455 )
456 .map(fallow_engine::project_analysis::ProjectAnalysisArtifacts::into_output)
457 .map(EditorProjectAnalysisOutput::from_engine)
458 }
459
460 const fn from_engine(inner: fallow_engine::session::AnalysisSession) -> Self {
461 Self { inner }
462 }
463}
464
465#[derive(Debug)]
467pub struct EditorProjectAnalysisOutput {
468 pub dead_code: EditorDeadCodeAnalysisOutput,
469 pub duplication: EditorDuplicationReport,
470}
471
472impl EditorProjectAnalysisOutput {
473 fn from_engine(output: fallow_engine::project_analysis::ProjectAnalysisOutput) -> Self {
474 Self {
475 dead_code: EditorDeadCodeAnalysisOutput::from_engine(output.dead_code),
476 duplication: output.duplication,
477 }
478 }
479}
480
481#[derive(Debug, Default)]
483pub struct EditorAnalysisOutput {
484 pub results: EditorAnalysisResults,
485 pub duplication: EditorDuplicationReport,
486}
487
488impl EditorAnalysisOutput {
489 #[must_use]
490 pub const fn new(results: EditorAnalysisResults, duplication: EditorDuplicationReport) -> Self {
491 Self {
492 results,
493 duplication,
494 }
495 }
496
497 #[must_use]
498 pub fn from_project_output(output: EditorProjectAnalysisOutput) -> Self {
499 Self::new(output.dead_code.results, output.duplication)
500 }
501
502 pub fn merge_project_output(&mut self, output: EditorProjectAnalysisOutput) {
503 self.merge_results(output.dead_code.results);
504 self.merge_duplication(output.duplication);
505 }
506
507 pub fn merge_results(&mut self, source: EditorAnalysisResults) {
508 self.results.merge_into(source);
509 }
510
511 pub fn merge_duplication(&mut self, source: EditorDuplicationReport) {
512 self.duplication.clone_groups.extend(source.clone_groups);
513 self.duplication
514 .clone_families
515 .extend(source.clone_families);
516 self.duplication
517 .mirrored_directories
518 .extend(source.mirrored_directories);
519 self.duplication.stats.clone_groups += source.stats.clone_groups;
520 self.duplication.stats.clone_instances += source.stats.clone_instances;
521 self.duplication.stats.total_files += source.stats.total_files;
522 self.duplication.stats.files_with_clones += source.stats.files_with_clones;
523 self.duplication.stats.total_lines += source.stats.total_lines;
524 self.duplication.stats.duplicated_lines += source.stats.duplicated_lines;
525 self.duplication.stats.total_tokens += source.stats.total_tokens;
526 self.duplication.stats.duplicated_tokens += source.stats.duplicated_tokens;
527 self.duplication.stats.clone_groups_below_min_occurrences +=
528 source.stats.clone_groups_below_min_occurrences;
529 self.duplication.stats.duplication_percentage = if self.duplication.stats.total_lines > 0 {
530 (self.duplication.stats.duplicated_lines as f64
531 / self.duplication.stats.total_lines as f64)
532 * 100.0
533 } else {
534 0.0
535 };
536 }
537
538 pub fn filter_by_changed_files(&mut self, changed_files: &FxHashSet<PathBuf>, root: &Path) {
539 fallow_engine::changed_files::filter_results_by_changed_files(
540 &mut self.results,
541 changed_files,
542 );
543 fallow_engine::changed_files::filter_duplication_by_changed_files(
544 &mut self.duplication,
545 changed_files,
546 root,
547 );
548 }
549
550 pub fn filter_by_changed_since(
551 &mut self,
552 root: &Path,
553 toplevel: &Path,
554 git_ref: &str,
555 ) -> Result<usize, ChangedFilesError> {
556 let changed = try_get_changed_files_with_toplevel(root, toplevel, git_ref)?;
557 let changed_count = changed.len();
558 self.filter_by_changed_files(&changed, root);
559 Ok(changed_count)
560 }
561}
562
563#[cfg(test)]
564mod tests {
565 use super::*;
566
567 use fallow_types::duplicates::{CloneGroup, CloneInstance, DuplicationStats};
568
569 #[test]
570 fn merges_duplication_stats_and_recomputes_percentage() {
571 let mut output = EditorAnalysisOutput {
572 duplication: EditorDuplicationReport {
573 clone_groups: vec![CloneGroup {
574 instances: vec![CloneInstance {
575 file: PathBuf::from("src/a.ts"),
576 start_line: 1,
577 end_line: 4,
578 start_col: 0,
579 end_col: 10,
580 fragment: "const a = 1;".to_string(),
581 }],
582 token_count: 8,
583 line_count: 4,
584 }],
585 clone_families: Vec::new(),
586 mirrored_directories: Vec::new(),
587 stats: DuplicationStats {
588 clone_groups: 1,
589 clone_instances: 1,
590 total_files: 1,
591 files_with_clones: 1,
592 total_lines: 20,
593 duplicated_lines: 4,
594 total_tokens: 80,
595 duplicated_tokens: 8,
596 duplication_percentage: 20.0,
597 clone_groups_below_min_occurrences: 1,
598 },
599 },
600 ..Default::default()
601 };
602
603 output.merge_duplication(EditorDuplicationReport {
604 clone_groups: Vec::new(),
605 clone_families: Vec::new(),
606 mirrored_directories: Vec::new(),
607 stats: DuplicationStats {
608 clone_groups: 0,
609 clone_instances: 0,
610 total_files: 1,
611 files_with_clones: 0,
612 total_lines: 30,
613 duplicated_lines: 6,
614 total_tokens: 120,
615 duplicated_tokens: 12,
616 duplication_percentage: 20.0,
617 clone_groups_below_min_occurrences: 2,
618 },
619 });
620
621 assert_eq!(output.duplication.stats.total_lines, 50);
622 assert_eq!(output.duplication.stats.duplicated_lines, 10);
623 assert_eq!(
624 output.duplication.stats.clone_groups_below_min_occurrences,
625 3
626 );
627 assert!((output.duplication.stats.duplication_percentage - 20.0).abs() < f64::EPSILON);
628 }
629
630 #[test]
631 fn editor_session_returns_api_owned_project_output() {
632 let temp = tempfile::tempdir().expect("temp project");
633 let root = temp.path();
634 std::fs::create_dir_all(root.join("src")).expect("src dir");
635 std::fs::write(
636 root.join("package.json"),
637 r#"{"name":"editor-api-session","main":"src/index.ts"}"#,
638 )
639 .expect("package.json");
640 std::fs::write(
641 root.join("src/index.ts"),
642 "export const used = 1;\nconsole.log(used);\n",
643 )
644 .expect("source");
645
646 let session = EditorAnalysisSession::load(root, None).expect("session loads");
647 let output = session
648 .analyze_project_with(&fallow_config::DuplicatesConfig::default(), true)
649 .expect("analysis runs");
650
651 assert!(output.dead_code.modules.is_some());
652 assert!(
653 output
654 .dead_code
655 .files
656 .as_ref()
657 .is_some_and(|files| !files.is_empty())
658 );
659 }
660
661 #[test]
662 fn editor_session_scopes_duplication_to_changed_files() {
663 let temp = tempfile::tempdir().expect("temp project");
664 let root = temp.path();
665 let src = root.join("src");
666 std::fs::create_dir_all(&src).expect("src dir");
667 std::fs::write(
668 root.join("package.json"),
669 r#"{"name":"editor-api-session","main":"src/a.ts"}"#,
670 )
671 .expect("package.json");
672 let repeated =
673 "export function repeated() {\n return ['alpha', 'beta', 'gamma'].join(',');\n}\n";
674 std::fs::write(src.join("a.ts"), repeated).expect("source a");
675 std::fs::write(src.join("b.ts"), repeated).expect("source b");
676
677 let session = EditorAnalysisSession::load(root, None).expect("session loads");
678 let mut config = session.config().duplicates.clone();
679 config.min_tokens = 1;
680 config.min_lines = 1;
681 let full = session
682 .analyze_project_with(&config, false)
683 .expect("analysis runs");
684 assert!(!full.duplication.clone_groups.is_empty());
685
686 let mut changed_files = FxHashSet::default();
687 changed_files.insert(src.join("unrelated.ts"));
688 let scoped = session
689 .analyze_project_with_changed_files(&config, false, Some(&changed_files))
690 .expect("analysis runs");
691 assert!(scoped.duplication.clone_groups.is_empty());
692 }
693
694 #[test]
695 fn build_health_ignore_set_returns_none_for_empty_patterns() {
696 assert!(
697 build_health_ignore_set(&[]).is_none(),
698 "empty ignore pattern list should avoid building a matcher"
699 );
700 }
701
702 #[test]
703 fn build_health_ignore_set_matches_glob_patterns() {
704 let set =
705 build_health_ignore_set(&["**/*.test.ts".to_string(), "src/generated/**".to_string()])
706 .expect("valid patterns build a glob set");
707
708 assert!(set.is_match(Path::new("src/foo.test.ts")));
709 assert!(set.is_match(Path::new("src/generated/client.ts")));
710 assert!(!set.is_match(Path::new("src/app.ts")));
711 }
712
713 #[test]
714 fn build_health_ignore_set_skips_invalid_patterns() {
715 let result = build_health_ignore_set(&["[invalid-glob".to_string()]);
716
717 match result {
718 None => {}
719 Some(set) => assert!(
720 !set.is_match(Path::new("any/path.ts")),
721 "set built from only invalid patterns must not match anything"
722 ),
723 }
724 }
725
726 fn make_inline_finding(path: PathBuf) -> EditorInlineComplexityFinding {
727 EditorInlineComplexityFinding {
728 path,
729 name: "myFn".to_string(),
730 line: 1,
731 col: 0,
732 cyclomatic: 5,
733 cognitive: 4,
734 exceeded: EditorInlineComplexityExceeded::Cyclomatic,
735 }
736 }
737
738 #[test]
739 fn filter_inline_complexity_keeps_findings_in_changed_set() {
740 let changed: FxHashSet<PathBuf> = [PathBuf::from("/src/a.ts"), PathBuf::from("/src/b.ts")]
741 .into_iter()
742 .collect();
743 let mut findings = vec![
744 make_inline_finding(PathBuf::from("/src/a.ts")),
745 make_inline_finding(PathBuf::from("/src/c.ts")),
746 ];
747
748 filter_inline_complexity_by_changed_files(&mut findings, &changed);
749
750 assert_eq!(findings.len(), 1);
751 assert_eq!(
752 findings[0].path.to_string_lossy().replace('\\', "/"),
753 "/src/a.ts"
754 );
755 }
756
757 #[test]
758 fn filter_inline_complexity_removes_all_when_changed_set_empty() {
759 let changed: FxHashSet<PathBuf> = FxHashSet::default();
760 let mut findings = vec![make_inline_finding(PathBuf::from("/src/a.ts"))];
761
762 filter_inline_complexity_by_changed_files(&mut findings, &changed);
763
764 assert!(
765 findings.is_empty(),
766 "empty changed-files set must drop all inline complexity findings"
767 );
768 }
769
770 #[test]
771 fn filter_inline_complexity_keeps_all_when_all_in_changed_set() {
772 let path_a = PathBuf::from("/src/a.ts");
773 let path_b = PathBuf::from("/src/b.ts");
774 let changed: FxHashSet<PathBuf> = [path_a.clone(), path_b.clone()].into_iter().collect();
775 let mut findings = vec![make_inline_finding(path_a), make_inline_finding(path_b)];
776
777 filter_inline_complexity_by_changed_files(&mut findings, &changed);
778
779 assert_eq!(
780 findings.len(),
781 2,
782 "all findings in the changed set must be retained"
783 );
784 }
785}