1use std::path::{Path, PathBuf};
4
5use rustc_hash::FxHashSet;
6
7use fallow_types::{discover::DiscoveredFile, extract::ModuleInfo};
8
9use crate::{ChangeScope, ChangeScopeRequest, PackageBaselineError};
10
11pub type EditorCloneFamily = fallow_types::duplicates::CloneFamily;
13pub type EditorCloneGroup = fallow_types::duplicates::CloneGroup;
15pub type EditorCloneInstance = fallow_types::duplicates::CloneInstance;
17pub type EditorDuplicationReport = fallow_types::duplicates::DuplicationReport;
19pub type EditorDuplicationStats = fallow_types::duplicates::DuplicationStats;
21pub type EditorMirroredDirectory = fallow_types::duplicates::MirroredDirectory;
23pub type EditorRefactoringKind = fallow_types::duplicates::RefactoringKind;
25pub type EditorRefactoringSuggestion = fallow_types::duplicates::RefactoringSuggestion;
27
28#[derive(Debug, Clone)]
30pub struct EditorCloneFingerprintSet {
31 inner: fallow_engine::duplicates::CloneFingerprintSet,
32}
33
34impl EditorCloneFingerprintSet {
35 #[must_use]
37 pub fn from_groups(groups: &[EditorCloneGroup]) -> Self {
38 Self {
39 inner: fallow_engine::duplicates::CloneFingerprintSet::from_groups(groups),
40 }
41 }
42
43 #[must_use]
45 pub fn fingerprint_for_group(&self, group: &EditorCloneGroup) -> String {
46 self.inner.fingerprint_for_group(group)
47 }
48
49 #[must_use]
51 pub fn fingerprint_for_parts(
52 &self,
53 instances: &[EditorCloneInstance],
54 token_count: usize,
55 line_count: usize,
56 ) -> String {
57 self.inner
58 .fingerprint_for_parts(instances, token_count, line_count)
59 }
60
61 #[must_use]
63 pub fn find_group<'a>(
64 &self,
65 groups: &'a [EditorCloneGroup],
66 fingerprint: &str,
67 ) -> Option<&'a EditorCloneGroup> {
68 self.inner.find_group(groups, fingerprint)
69 }
70}
71
72pub mod editor_duplicates {
75 pub use crate::editor::{
76 EditorCloneFamily as CloneFamily, EditorCloneFingerprintSet as CloneFingerprintSet,
77 EditorCloneGroup as CloneGroup, EditorCloneInstance as CloneInstance,
78 EditorDuplicationReport as DuplicationReport, EditorDuplicationStats as DuplicationStats,
79 EditorMirroredDirectory as MirroredDirectory, EditorRefactoringKind as RefactoringKind,
80 EditorRefactoringSuggestion as RefactoringSuggestion,
81 };
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
86pub enum ChangedFilesError {
87 InvalidRef(String),
89 GitMissing(String),
91 NotARepository,
93 GitFailed(String),
95}
96
97impl ChangedFilesError {
98 #[must_use]
100 pub fn describe(&self) -> String {
101 match self {
102 Self::InvalidRef(err) => format!("invalid git ref: {err}"),
103 Self::GitMissing(err) => format!("failed to run git: {err}"),
104 Self::NotARepository => "not a git repository".to_owned(),
105 Self::GitFailed(stderr) => {
106 let lower = stderr.to_ascii_lowercase();
107 if lower.contains("not a valid object name")
108 || lower.contains("unknown revision")
109 || lower.contains("ambiguous argument")
110 {
111 format!(
112 "{stderr} (shallow clone? try `git fetch --unshallow`, or set `fetch-depth: 0` on actions/checkout / `GIT_DEPTH: 0` in GitLab CI)"
113 )
114 } else {
115 stderr.clone()
116 }
117 }
118 }
119 }
120}
121
122impl From<fallow_engine::changed_files::ChangedFilesError> for ChangedFilesError {
123 fn from(error: fallow_engine::changed_files::ChangedFilesError) -> Self {
124 match error {
125 fallow_engine::changed_files::ChangedFilesError::InvalidRef(err) => {
126 Self::InvalidRef(err)
127 }
128 fallow_engine::changed_files::ChangedFilesError::GitMissing(err) => {
129 Self::GitMissing(err)
130 }
131 fallow_engine::changed_files::ChangedFilesError::NotARepository => Self::NotARepository,
132 fallow_engine::changed_files::ChangedFilesError::GitFailed(stderr) => {
133 Self::GitFailed(stderr)
134 }
135 }
136 }
137}
138
139pub fn resolve_git_toplevel(cwd: &Path) -> Result<PathBuf, ChangedFilesError> {
146 fallow_engine::changed_files::resolve_git_toplevel(cwd).map_err(ChangedFilesError::from)
147}
148
149pub fn try_get_changed_files_with_toplevel(
156 cwd: &Path,
157 toplevel: &Path,
158 git_ref: &str,
159) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
160 fallow_engine::changed_files::try_get_changed_files_with_toplevel(cwd, toplevel, git_ref)
161 .map_err(ChangedFilesError::from)
162}
163
164pub mod editor_extract {
167 pub use fallow_types::extract::{
168 AngularComponentSelector, AngularInputMember, AngularOutputMember,
169 AngularTemplateMemberAccessFact, AngularThisSpreadFact, CalleeUse, ClassHeritageInfo,
170 ComplexityContribution, ComplexityContributionKind, ComplexityMetric, ComponentEmit,
171 ComponentFunction, ComponentFunctionKind, ComponentProp, CssAnalytics, CssDeclarationBlock,
172 CssRuleMetric, DiFramework, DiKeySite, DiRole, DispatchedEvent,
173 DynamicCustomElementRenderFact, DynamicImportInfo, DynamicImportPattern, ExportInfo,
174 ExportName, FactoryCallMemberAccessFact, FactoryFnMemberAccessFact, FactoryReturnExport,
175 FlagUse, FlagUseKind, FluentChainMemberAccessFact, FluentChainNewMemberAccessFact,
176 ForwardAttr, FunctionComplexity, HookUse, HookUseKind, ImportInfo, ImportedName,
177 InstanceExportBindingFact, LoadReturnKey, LocalTypeDeclaration, MemberAccess, MemberInfo,
178 MemberKind, MisplacedDirectiveSite, ModuleInfo, NamespaceObjectAlias, PUBLIC_ENV_EXACT,
179 PUBLIC_ENV_METADATA_TOKENS, PUBLIC_ENV_PREFIXES, ParseResult, PlaywrightFixtureAliasFact,
180 PlaywrightFixtureDefinitionFact, PlaywrightFixtureTypeFact, PlaywrightFixtureUseFact,
181 PublicSignatureTypeReference, ReExportInfo, RegisteredCustomElement, RenderEdge,
182 RequireCallInfo, SECRET_ENV_TOKENS, SanitizedSinkArg, SanitizerScope, SecurityControlKind,
183 SecurityControlSite, SecurityUrlShape, SemanticFact, SemanticFactView, SinkArgKind,
184 SinkLiteralValue, SinkObjectProperty, SinkShape, SinkSite,
185 SkippedSecurityCalleeExpressionKind, SkippedSecurityCalleeReason,
186 SkippedSecurityCalleeSite, TaintedBinding, VisibilityTag,
187 };
188}
189
190pub mod editor_results {
193 pub use fallow_types::output_dead_code::{
194 AbsentComponentPropFinding, BoundaryCallViolationFinding, BoundaryCoverageViolationFinding,
195 BoundaryViolationFinding, CircularDependencyFinding, DeprecatedExportInUseFinding,
196 DevDependencyInProductionFinding, DuplicateExportFinding, DuplicatePropShapeFinding,
197 DynamicSegmentNameConflictFinding, EmptyCatalogGroupFinding, InvalidClientExportFinding,
198 MisconfiguredDependencyOverrideFinding, MisplacedDirectiveFinding,
199 MixedClientServerBarrelFinding, PackageCycleFinding, PolicyViolationFinding,
200 PrivateTypeLeakFinding, PropDrillingChainFinding, ReExportCycleFinding,
201 RouteCollisionFinding, TestOnlyDependencyFinding, ThinWrapperFinding,
202 TypeOnlyDependencyFinding, UnlistedDependencyFinding, UnprovidedInjectFinding,
203 UnrenderedComponentFinding, UnresolvedCatalogReferenceFinding, UnresolvedImportFinding,
204 UnusedCatalogEntryFinding, UnusedClassMemberFinding, UnusedComponentEmitFinding,
205 UnusedComponentInputFinding, UnusedComponentOutputFinding, UnusedComponentPropFinding,
206 UnusedDependencyFinding, UnusedDependencyOverrideFinding, UnusedDevDependencyFinding,
207 UnusedEnumMemberFinding, UnusedExportFinding, UnusedFileFinding, UnusedLoadDataKeyFinding,
208 UnusedOptionalDependencyFinding, UnusedServerActionFinding, UnusedStoreMemberFinding,
209 UnusedSvelteEventFinding, UnusedTypeFinding,
210 };
211 pub use fallow_types::results::{
212 AbsentComponentProp, ActiveSuppression, AnalysisResults, BoundaryCallViolation,
213 BoundaryCoverageViolation, BoundaryViolation, CircularDependency, CircularDependencyEdge,
214 ComponentPropCallSite, DependencyLocation, DependencyOverrideMisconfigReason,
215 DependencyOverrideSource, DeprecatedConsumerKind, DeprecatedExportConsumer,
216 DeprecatedExportInUse, DevDependencyInProduction, DuplicateExport, DuplicateLocation,
217 DuplicatePropShape, DuplicatePropShapeMember, DynamicSegmentNameConflict,
218 EmptyCatalogGroup, EntryPointSummary, ExportUsage, FeatureFlag, FlagConfidence, FlagKind,
219 ImportSite, InvalidClientExport, MisconfiguredDependencyOverride, MisplacedDirective,
220 MixedClientServerBarrel, PackageCycle, PackageCycleEdge, PolicyRuleKind, PolicyViolation,
221 PolicyViolationSeverity, PrivateTypeLeak, PropDrillHop, PropDrillingChain, ReExportCycle,
222 ReExportCycleKind, ReactComponentIntel, ReactHookSummary, ReactPropDrill, ReactPropIntel,
223 ReferenceLocation, RenderFanInComponent, RenderFanInMetric, RouteCollision,
224 SecurityAttackSurfaceEntry, SecurityCandidate, SecurityCandidateBoundary,
225 SecurityCandidateSink, SecurityDeadCodeContext, SecurityDeadCodeKind,
226 SecurityDefensiveBoundary, SecurityDefensiveControl, SecurityFinding, SecurityFindingKind,
227 SecurityNetworkContext, SecurityReachability, SecurityRuntimeContext, SecurityRuntimeState,
228 SecuritySeverity, SecurityTaintFlow, SecurityUnresolvedCalleeDiagnostic,
229 SecurityZoneCrossing, StaleSuppression, SuppressionOrigin, TaintConfidence, TaintEndpoint,
230 TaintPath, TestOnlyDependency, ThinWrapper, TraceHop, TraceHopRole, TypeOnlyDependency,
231 UnlistedDependency, UnprovidedInject, UnrenderedComponent, UnresolvedCatalogReference,
232 UnresolvedImport, UnusedCatalogEntry, UnusedComponentEmit, UnusedComponentInput,
233 UnusedComponentOutput, UnusedComponentProp, UnusedDependency, UnusedDependencyOverride,
234 UnusedExport, UnusedFile, UnusedLoadDataKey, UnusedMember, UnusedServerAction,
235 UnusedSvelteEvent,
236 };
237}
238
239pub mod editor_security {
241 #[must_use]
243 pub fn security_catalogue_title(kind: &str) -> Option<&'static str> {
244 fallow_engine::dead_code::security_catalogue_title(kind)
245 }
246}
247
248pub mod editor_suppress {
250 pub use fallow_types::suppress::{IssueKind, is_suppressed};
251}
252
253pub type EditorAnalysisResults = fallow_types::results::AnalysisResults;
255
256#[derive(Debug)]
261pub struct EditorDeadCodeAnalysisOutput {
262 pub results: EditorAnalysisResults,
264 pub modules: Option<Vec<ModuleInfo>>,
267 pub files: Option<Vec<DiscoveredFile>>,
269}
270
271impl EditorDeadCodeAnalysisOutput {
272 fn from_engine(output: fallow_engine::dead_code::DeadCodeAnalysisOutput) -> Self {
273 Self {
274 results: output.results,
275 modules: output.modules,
276 files: output.files,
277 }
278 }
279}
280
281#[derive(Debug, Clone, PartialEq, Eq)]
287pub struct EditorInlineComplexityFinding {
288 pub path: PathBuf,
290 pub name: String,
292 pub line: u32,
294 pub col: u32,
296 pub cyclomatic: u16,
298 pub cognitive: u16,
300 pub exceeded: EditorInlineComplexityExceeded,
302}
303
304#[derive(Debug, Clone, Copy, PartialEq, Eq)]
306pub enum EditorInlineComplexityExceeded {
307 Cyclomatic,
309 Cognitive,
311 CyclomaticAndCognitive,
313}
314
315#[must_use]
321pub fn collect_inline_complexity(
322 config: &fallow_config::ResolvedConfig,
323 output: &EditorDeadCodeAnalysisOutput,
324) -> Vec<EditorInlineComplexityFinding> {
325 let (Some(modules), Some(files)) = (output.modules.as_ref(), output.files.as_ref()) else {
326 return Vec::new();
327 };
328 fallow_engine::health::inline_complexity(config, modules, files)
329 .into_iter()
330 .map(|finding| EditorInlineComplexityFinding {
331 exceeded: match (finding.exceeds_cyclomatic, finding.exceeds_cognitive) {
332 (true, true) => EditorInlineComplexityExceeded::CyclomaticAndCognitive,
333 (true, false) => EditorInlineComplexityExceeded::Cyclomatic,
334 (false, _) => EditorInlineComplexityExceeded::Cognitive,
335 },
336 path: finding.path,
337 name: finding.name,
338 line: finding.line,
339 col: finding.col,
340 cyclomatic: finding.cyclomatic,
341 cognitive: finding.cognitive,
342 })
343 .collect()
344}
345
346#[allow(
348 clippy::implicit_hasher,
349 reason = "editor analysis changed-file sets use the workspace FxHashSet convention"
350)]
351pub fn filter_inline_complexity_by_changed_files(
352 findings: &mut Vec<EditorInlineComplexityFinding>,
353 changed_files: &FxHashSet<PathBuf>,
354) {
355 findings.retain(|finding| changed_files.contains(&finding.path));
356}
357
358pub fn filter_inline_complexity_by_change_scope(
360 findings: &mut Vec<EditorInlineComplexityFinding>,
361 scope: &ChangeScope,
362) {
363 findings.retain(|finding| scope.contains(&finding.path));
364}
365
366pub type EditorSessionParseCounts = fallow_engine::session::SessionParseCounts;
369
370#[derive(Debug)]
372pub struct EditorAnalysisSession {
373 inner: fallow_engine::session::AnalysisSession,
374}
375
376impl EditorAnalysisSession {
377 pub fn load(root: &Path, config_path: Option<&Path>) -> fallow_engine::EngineResult<Self> {
383 fallow_engine::session::AnalysisSession::load(root, config_path).map(Self::from_engine)
384 }
385
386 pub fn load_with_config(
392 root: &Path,
393 config_path: Option<&Path>,
394 configure: impl FnOnce(&mut fallow_config::ResolvedConfig),
395 ) -> fallow_engine::EngineResult<Self> {
396 fallow_engine::session::AnalysisSession::load_with_config(root, config_path, configure)
397 .map(Self::from_engine)
398 }
399
400 pub fn load_with_config_options(
407 root: &Path,
408 config_path: Option<&Path>,
409 load_options: fallow_config::ConfigLoadOptions,
410 configure: impl FnOnce(&mut fallow_config::ResolvedConfig),
411 ) -> fallow_engine::EngineResult<Self> {
412 fallow_engine::session::AnalysisSession::load_with_config_options(
413 root,
414 config_path,
415 load_options,
416 configure,
417 )
418 .map(Self::from_engine)
419 }
420
421 #[must_use]
423 pub fn load_default(root: &Path) -> Self {
424 Self::from_engine(fallow_engine::session::AnalysisSession::load_default(root))
425 }
426
427 #[must_use]
430 pub fn load_default_with_config(
431 root: &Path,
432 configure: impl FnOnce(&mut fallow_config::ResolvedConfig),
433 ) -> Self {
434 Self::from_engine(
435 fallow_engine::session::AnalysisSession::load_default_with_config(root, configure),
436 )
437 }
438
439 #[must_use]
448 pub fn with_cancellation(
449 self,
450 cancellation: std::sync::Arc<std::sync::atomic::AtomicBool>,
451 ) -> Self {
452 Self::from_engine(self.inner.with_cancellation(cancellation))
453 }
454
455 pub fn set_cancellation(
457 &mut self,
458 cancellation: std::sync::Arc<std::sync::atomic::AtomicBool>,
459 ) {
460 self.inner.set_cancellation(cancellation);
461 }
462
463 pub fn refresh_discovery(&mut self) -> bool {
468 self.inner.refresh_discovery()
469 }
470
471 #[must_use]
475 pub fn config_inputs(&self) -> &fallow_config::ConfigInputs {
476 self.inner.config_inputs()
477 }
478
479 #[must_use]
482 pub fn config_inputs_before_resolve(&self) -> &fallow_config::ConfigInputsSnapshot {
483 self.inner.config_inputs_before_resolve()
484 }
485
486 #[must_use]
489 pub fn retained_bytes_estimate(&self) -> u64 {
490 self.inner.retained_bytes_estimate()
491 }
492
493 pub fn flush_parse_cache(&self) {
496 self.inner.flush_parse_cache();
497 }
498
499 pub fn prewarm(&self, retain_complexity_artifacts: bool) -> fallow_engine::EngineResult<()> {
506 self.inner
507 .prewarm_parsed_modules(retain_complexity_artifacts)
508 }
509
510 #[must_use]
512 pub fn parse_counts(&self) -> EditorSessionParseCounts {
513 self.inner.parse_counts()
514 }
515
516 #[must_use]
518 pub fn config(&self) -> &fallow_config::ResolvedConfig {
519 self.inner.config()
520 }
521
522 pub fn change_scope(
530 &self,
531 request: ChangeScopeRequest<'_>,
532 ) -> Result<ChangeScope, PackageBaselineError> {
533 ChangeScope::resolve(request, self.inner.config(), self.inner.workspaces())
534 }
535
536 #[must_use]
541 pub fn unmatched_config_patterns(&self) -> Vec<fallow_config::WorkspaceDiagnostic> {
542 fallow_engine::dead_code::config_pattern_diagnostics(self.inner.config(), true)
543 }
544
545 #[must_use]
547 pub fn config_path(&self) -> Option<&Path> {
548 self.inner.config_path()
549 }
550
551 pub fn refine_type_aware_dead_code(
559 &self,
560 options: &crate::TypeAwareOptions,
561 filters: &crate::DeadCodeFilters,
562 output: &mut EditorDeadCodeAnalysisOutput,
563 ) -> Result<Option<fallow_types::envelope::TypeAwareMeta>, crate::ProgrammaticError> {
564 let meta = crate::type_aware::refine_programmatic_dead_code(
565 options,
566 filters,
567 &self.inner,
568 &mut output.results,
569 )?;
570 fallow_engine::dead_code::apply_rule_severities(&mut output.results, self.inner.config());
574 Ok(meta)
575 }
576
577 pub fn refine_type_aware_dead_code_in_session(
579 &self,
580 semantic_session: &mut crate::TypeAwareSession,
581 changes: Option<&crate::TypeAwareFileChanges>,
582 options: &crate::TypeAwareOptions,
583 filters: &crate::DeadCodeFilters,
584 output: &mut EditorDeadCodeAnalysisOutput,
585 ) -> Result<Option<fallow_types::envelope::TypeAwareMeta>, crate::ProgrammaticError> {
586 let meta = crate::type_aware::refine_programmatic_dead_code_in_session(
587 semantic_session,
588 changes,
589 options,
590 filters,
591 &self.inner,
592 &mut output.results,
593 )?;
594 fallow_engine::dead_code::apply_rule_severities(&mut output.results, self.inner.config());
595 Ok(meta)
596 }
597
598 pub fn analyze_project_with(
604 &self,
605 duplicates_config: &fallow_config::DuplicatesConfig,
606 retain_complexity_artifacts: bool,
607 ) -> fallow_engine::EngineResult<EditorProjectAnalysisOutput> {
608 self.inner
609 .analyze_project_with(duplicates_config, retain_complexity_artifacts)
610 .map(EditorProjectAnalysisOutput::from_engine)
611 .map(|output| self.with_resolved_rule_severities(output))
612 }
613
614 pub fn analyze_project_with_changed_files(
627 &self,
628 duplicates_config: &fallow_config::DuplicatesConfig,
629 retain_complexity_artifacts: bool,
630 changed_files: Option<&FxHashSet<PathBuf>>,
631 ) -> fallow_engine::EngineResult<EditorProjectAnalysisOutput> {
632 self.inner
633 .analyze_project_with_artifacts(
634 duplicates_config,
635 fallow_engine::project_analysis::ProjectAnalysisArtifactOptions {
636 retain_complexity_artifacts,
637 changed_files: changed_files.cloned(),
638 ..fallow_engine::project_analysis::ProjectAnalysisArtifactOptions::default()
639 },
640 )
641 .map(fallow_engine::project_analysis::ProjectAnalysisArtifacts::into_output)
642 .map(EditorProjectAnalysisOutput::from_engine)
643 .map(|output| self.with_resolved_rule_severities(output))
644 }
645
646 pub fn apply_change_scope(
657 &self,
658 output: &mut EditorProjectAnalysisOutput,
659 scope: &ChangeScope,
660 ) {
661 fallow_engine::dead_code::apply_scope(
662 &mut output.dead_code.results,
663 &fallow_engine::dead_code::DeadCodeScope {
664 workspace_roots: None,
665 changes: Some(scope),
666 diff: None,
667 files: None,
668 },
669 self.inner.config(),
670 );
671 fallow_engine::duplicates::apply_scope(
672 &mut output.duplication,
673 &fallow_engine::duplicates::DuplicationScope {
674 changes: Some(scope),
675 diff: None,
676 workspace_roots: None,
677 },
678 &self.inner.config().root,
679 );
680 }
681
682 fn with_resolved_rule_severities(
689 &self,
690 mut output: EditorProjectAnalysisOutput,
691 ) -> EditorProjectAnalysisOutput {
692 fallow_engine::dead_code::apply_rule_severities(
693 &mut output.dead_code.results,
694 self.inner.config(),
695 );
696 output
697 }
698
699 const fn from_engine(inner: fallow_engine::session::AnalysisSession) -> Self {
700 Self { inner }
701 }
702}
703
704#[derive(Debug)]
706pub struct EditorProjectAnalysisOutput {
707 pub dead_code: EditorDeadCodeAnalysisOutput,
709 pub duplication: EditorDuplicationReport,
711}
712
713impl EditorProjectAnalysisOutput {
714 fn from_engine(output: fallow_engine::project_analysis::ProjectAnalysisOutput) -> Self {
715 Self {
716 dead_code: EditorDeadCodeAnalysisOutput::from_engine(output.dead_code),
717 duplication: output.duplication,
718 }
719 }
720}
721
722#[derive(Debug, Default)]
724pub struct EditorAnalysisOutput {
725 pub results: EditorAnalysisResults,
727 pub duplication: EditorDuplicationReport,
729}
730
731impl EditorAnalysisOutput {
732 #[must_use]
734 pub const fn new(results: EditorAnalysisResults, duplication: EditorDuplicationReport) -> Self {
735 Self {
736 results,
737 duplication,
738 }
739 }
740
741 pub fn merge_project_output(&mut self, output: EditorProjectAnalysisOutput) {
743 self.merge_results(output.dead_code.results);
744 self.merge_duplication(output.duplication);
745 }
746
747 pub fn merge_results(&mut self, source: EditorAnalysisResults) {
749 self.results.merge_into(source);
750 }
751
752 pub fn merge_duplication(&mut self, source: EditorDuplicationReport) {
755 self.duplication.clone_groups.extend(source.clone_groups);
756 self.duplication
757 .clone_families
758 .extend(source.clone_families);
759 self.duplication
760 .mirrored_directories
761 .extend(source.mirrored_directories);
762 self.duplication.stats.clone_groups += source.stats.clone_groups;
763 self.duplication.stats.clone_families += source.stats.clone_families;
764 self.duplication.stats.clone_instances += source.stats.clone_instances;
765 self.duplication.stats.total_files += source.stats.total_files;
766 self.duplication.stats.files_with_clones += source.stats.files_with_clones;
767 self.duplication.stats.total_lines += source.stats.total_lines;
768 self.duplication.stats.duplicated_lines += source.stats.duplicated_lines;
769 self.duplication.stats.total_tokens += source.stats.total_tokens;
770 self.duplication.stats.duplicated_tokens += source.stats.duplicated_tokens;
771 self.duplication.stats.clone_groups_below_min_occurrences +=
772 source.stats.clone_groups_below_min_occurrences;
773 self.duplication.stats.clone_groups_ignored += source.stats.clone_groups_ignored;
774 self.duplication.stats.near_candidates_skipped += source.stats.near_candidates_skipped;
775 self.duplication.stats.duplication_percentage = if self.duplication.stats.total_lines > 0 {
776 (self.duplication.stats.duplicated_lines as f64
777 / self.duplication.stats.total_lines as f64)
778 * 100.0
779 } else {
780 0.0
781 };
782 }
783
784 pub fn filter_by_changed_files(&mut self, changed_files: &FxHashSet<PathBuf>, root: &Path) {
793 fallow_engine::changed_files::filter_results_by_changed_files(
794 &mut self.results,
795 changed_files,
796 );
797 fallow_engine::changed_files::filter_duplication_by_changed_files(
798 &mut self.duplication,
799 changed_files,
800 root,
801 );
802 }
803}
804
805#[cfg(test)]
806pub(crate) mod tests {
807 use super::*;
808
809 use fallow_types::duplicates::{CloneFamily, CloneGroup, CloneInstance, DuplicationStats};
810
811 use super::editor_results::{
812 BoundaryViolation, BoundaryViolationFinding, CircularDependency, CircularDependencyFinding,
813 DevDependencyInProduction, DevDependencyInProductionFinding, ExportUsage, SecuritySeverity,
814 TestOnlyDependency, TestOnlyDependencyFinding, TypeOnlyDependency, UnlistedDependency,
815 UnlistedDependencyFinding, UnusedClassMemberFinding, UnusedDependency,
816 UnusedDependencyFinding, UnusedDevDependencyFinding, UnusedEnumMemberFinding, UnusedExport,
817 UnusedExportFinding, UnusedFile, UnusedFileFinding, UnusedMember,
818 UnusedOptionalDependencyFinding, UnusedStoreMemberFinding, UnusedTypeFinding,
819 };
820
821 #[test]
822 fn merges_duplication_stats_and_recomputes_percentage() {
823 let mut output = EditorAnalysisOutput {
824 duplication: EditorDuplicationReport {
825 clone_groups: vec![CloneGroup {
826 instances: vec![CloneInstance {
827 is_symlink: false,
828 file: PathBuf::from("src/a.ts"),
829 start_line: 1,
830 end_line: 4,
831 start_col: 0,
832 end_col: 10,
833 fragment: "const a = 1;".to_string(),
834 }],
835 token_count: 8,
836 line_count: 4,
837 similarity: None,
838 }],
839 clone_families: Vec::new(),
840 mirrored_directories: Vec::new(),
841 stats: DuplicationStats {
842 clone_groups: 1,
843 clone_families: 0,
844 clone_instances: 1,
845 total_files: 1,
846 files_with_clones: 1,
847 total_lines: 20,
848 duplicated_lines: 4,
849 total_tokens: 80,
850 duplicated_tokens: 8,
851 duplication_percentage: 20.0,
852 clone_groups_below_min_occurrences: 1,
853 clone_groups_ignored: 1,
854 near_candidates_skipped: 2,
855 },
856 },
857 ..Default::default()
858 };
859
860 output.merge_duplication(EditorDuplicationReport {
861 clone_groups: Vec::new(),
862 clone_families: Vec::new(),
863 mirrored_directories: Vec::new(),
864 stats: DuplicationStats {
865 clone_groups: 0,
866 clone_families: 0,
867 clone_instances: 0,
868 total_files: 1,
869 files_with_clones: 0,
870 total_lines: 30,
871 duplicated_lines: 6,
872 total_tokens: 120,
873 duplicated_tokens: 12,
874 duplication_percentage: 20.0,
875 clone_groups_below_min_occurrences: 2,
876 clone_groups_ignored: 3,
877 near_candidates_skipped: 4,
878 },
879 });
880
881 assert_eq!(output.duplication.stats.total_lines, 50);
882 assert_eq!(output.duplication.stats.duplicated_lines, 10);
883 assert_eq!(
884 output.duplication.stats.clone_groups_below_min_occurrences,
885 3
886 );
887 assert_eq!(output.duplication.stats.clone_groups_ignored, 4);
888 assert_eq!(output.duplication.stats.near_candidates_skipped, 6);
889 assert!((output.duplication.stats.duplication_percentage - 20.0).abs() < f64::EPSILON);
890 }
891
892 #[test]
893 fn merging_duplication_keeps_the_family_corpus_count_aligned() {
894 let family = |path: &str| CloneFamily {
895 files: vec![PathBuf::from(path)],
896 groups: Vec::new(),
897 total_duplicated_lines: 4,
898 total_duplicated_tokens: 8,
899 suggestions: Vec::new(),
900 };
901 let report = |path: &str, families: usize| EditorDuplicationReport {
902 clone_groups: Vec::new(),
903 clone_families: vec![family(path)],
904 mirrored_directories: Vec::new(),
905 stats: DuplicationStats {
906 clone_families: families,
907 ..DuplicationStats::default()
908 },
909 };
910
911 let mut output = EditorAnalysisOutput {
912 duplication: report("src/a.ts", 3),
913 ..Default::default()
914 };
915 output.merge_duplication(report("src/b.ts", 2));
916
917 assert_eq!(output.duplication.stats.clone_families, 5);
918 assert_eq!(output.duplication.clone_families_shown(), 2);
919 assert_eq!(output.duplication.clone_families_omitted(), 3);
920 assert_eq!(
921 output.duplication.clone_families_total(),
922 output.duplication.stats.clone_families
923 );
924 }
925
926 #[test]
927 fn editor_session_returns_api_owned_project_output() {
928 let temp = tempfile::tempdir().expect("temp project");
929 let root = temp.path();
930 std::fs::create_dir_all(root.join("src")).expect("src dir");
931 std::fs::write(
932 root.join("package.json"),
933 r#"{"name":"editor-api-session","main":"src/index.ts"}"#,
934 )
935 .expect("package.json");
936 std::fs::write(
937 root.join("src/index.ts"),
938 "export const used = 1;\nconsole.log(used);\n",
939 )
940 .expect("source");
941
942 let session = EditorAnalysisSession::load(root, None).expect("session loads");
943 let output = session
944 .analyze_project_with(&fallow_config::DuplicatesConfig::default(), true)
945 .expect("analysis runs");
946
947 assert!(output.dead_code.modules.is_some());
948 assert!(
949 output
950 .dead_code
951 .files
952 .as_ref()
953 .is_some_and(|files| !files.is_empty())
954 );
955 }
956
957 #[test]
961 fn changed_files_scope_runs_after_the_analysis_keeps_all_unused_files() {
962 let temp = tempfile::tempdir().expect("temp project");
963 let root = temp.path().canonicalize().expect("canonical root");
964 let src = root.join("src");
965 std::fs::create_dir_all(&src).expect("src dir");
966 std::fs::write(
967 root.join("package.json"),
968 r#"{"name":"editor-scope-order","main":"src/a.ts"}"#,
969 )
970 .expect("package.json");
971 std::fs::write(src.join("a.ts"), "export const a = 1;\n").expect("source a");
972 std::fs::write(src.join("orphan.ts"), "export const orphan = 1;\n").expect("orphan");
973
974 let session = EditorAnalysisSession::load(&root, None).expect("session loads");
975 let mut changed_files = FxHashSet::default();
976 changed_files.insert(src.join("a.ts"));
977 let mut output = session
978 .analyze_project_with_changed_files(
979 &fallow_config::DuplicatesConfig::default(),
980 false,
981 Some(&changed_files),
982 )
983 .expect("analysis runs");
984 let unused_files = |output: &EditorProjectAnalysisOutput| {
985 output
986 .dead_code
987 .results
988 .unused_files
989 .iter()
990 .map(|finding| finding.file.path.clone())
991 .collect::<Vec<_>>()
992 };
993 assert_eq!(
994 unused_files(&output),
995 vec![src.join("orphan.ts")],
996 "the analysis keeps the unused file outside the changed set"
997 );
998
999 session.apply_change_scope(&mut output, &ChangeScope::changed_files(&changed_files));
1000 assert!(
1001 unused_files(&output).is_empty(),
1002 "the scope removes the unused file outside the changed set: {:?}",
1003 unused_files(&output)
1004 );
1005 }
1006
1007 #[test]
1008 fn editor_session_scopes_duplication_to_changed_files() {
1009 let temp = tempfile::tempdir().expect("temp project");
1010 let root = temp.path();
1011 let src = root.join("src");
1012 std::fs::create_dir_all(&src).expect("src dir");
1013 std::fs::write(
1014 root.join("package.json"),
1015 r#"{"name":"editor-api-session","main":"src/a.ts"}"#,
1016 )
1017 .expect("package.json");
1018 let repeated =
1019 "export function repeated() {\n return ['alpha', 'beta', 'gamma'].join(',');\n}\n";
1020 std::fs::write(src.join("a.ts"), repeated).expect("source a");
1021 std::fs::write(src.join("b.ts"), repeated).expect("source b");
1022
1023 let session = EditorAnalysisSession::load(root, None).expect("session loads");
1024 let mut config = session.config().duplicates.clone();
1025 config.min_tokens = 1;
1026 config.min_lines = 1;
1027 let full = session
1028 .analyze_project_with(&config, false)
1029 .expect("analysis runs");
1030 assert!(!full.duplication.clone_groups.is_empty());
1031
1032 let mut changed_files = FxHashSet::default();
1033 changed_files.insert(src.join("unrelated.ts"));
1034 let scoped = session
1035 .analyze_project_with_changed_files(&config, false, Some(&changed_files))
1036 .expect("analysis runs");
1037 assert!(scoped.duplication.clone_groups.is_empty());
1038 }
1039
1040 #[test]
1043 fn inline_complexity_applies_health_threshold_overrides() {
1044 let temp = tempfile::tempdir().expect("temp project");
1045 let root = temp.path();
1046 std::fs::create_dir_all(root.join("src")).expect("src dir");
1047 std::fs::write(
1048 root.join("package.json"),
1049 r#"{"name":"editor-inline-overrides","main":"src/index.ts"}"#,
1050 )
1051 .expect("package.json");
1052 std::fs::write(
1053 root.join(".fallowrc.json"),
1054 r#"{"health":{"maxCyclomatic":2,"maxCognitive":2,"thresholdOverrides":[{"files":["src/legacy.ts"],"maxCyclomatic":50,"maxCognitive":50}]}}"#,
1055 )
1056 .expect("config");
1057 let branchy = |name: &str| {
1058 format!(
1059 "export function {name}(value: number): number {{\n if (value > 1) {{ return 1; }}\n \
1060 if (value > 2) {{ return 2; }}\n if (value > 3) {{ return 3; }}\n return 0;\n}}\n"
1061 )
1062 };
1063 std::fs::write(root.join("src/app.ts"), branchy("appBranchy")).expect("app");
1064 std::fs::write(root.join("src/legacy.ts"), branchy("legacyBranchy")).expect("legacy");
1065 std::fs::write(
1066 root.join("src/index.ts"),
1067 "export { appBranchy } from \"./app\";\nexport { legacyBranchy } from \"./legacy\";\n",
1068 )
1069 .expect("index");
1070
1071 let session = EditorAnalysisSession::load(root, None).expect("session loads");
1072 let output = session
1073 .analyze_project_with(&session.config().duplicates.clone(), true)
1074 .expect("analysis runs");
1075 let names = collect_inline_complexity(session.config(), &output.dead_code)
1076 .into_iter()
1077 .map(|finding| finding.name)
1078 .collect::<Vec<_>>();
1079
1080 assert_eq!(
1081 names,
1082 vec!["appBranchy".to_string()],
1083 "the override raises the ceilings for src/legacy.ts, as in `fallow health`"
1084 );
1085 }
1086
1087 fn make_inline_finding(path: PathBuf) -> EditorInlineComplexityFinding {
1088 EditorInlineComplexityFinding {
1089 path,
1090 name: "myFn".to_string(),
1091 line: 1,
1092 col: 0,
1093 cyclomatic: 5,
1094 cognitive: 4,
1095 exceeded: EditorInlineComplexityExceeded::Cyclomatic,
1096 }
1097 }
1098
1099 #[test]
1100 fn filter_inline_complexity_keeps_findings_in_changed_set() {
1101 let changed: FxHashSet<PathBuf> = [PathBuf::from("/src/a.ts"), PathBuf::from("/src/b.ts")]
1102 .into_iter()
1103 .collect();
1104 let mut findings = vec![
1105 make_inline_finding(PathBuf::from("/src/a.ts")),
1106 make_inline_finding(PathBuf::from("/src/c.ts")),
1107 ];
1108
1109 filter_inline_complexity_by_changed_files(&mut findings, &changed);
1110
1111 assert_eq!(findings.len(), 1);
1112 assert_eq!(
1113 findings[0].path.to_string_lossy().replace('\\', "/"),
1114 "/src/a.ts"
1115 );
1116 }
1117
1118 #[test]
1119 fn filter_inline_complexity_removes_all_when_changed_set_empty() {
1120 let changed: FxHashSet<PathBuf> = FxHashSet::default();
1121 let mut findings = vec![make_inline_finding(PathBuf::from("/src/a.ts"))];
1122
1123 filter_inline_complexity_by_changed_files(&mut findings, &changed);
1124
1125 assert!(
1126 findings.is_empty(),
1127 "empty changed-files set must drop all inline complexity findings"
1128 );
1129 }
1130
1131 #[test]
1132 fn filter_inline_complexity_keeps_all_when_all_in_changed_set() {
1133 let path_a = PathBuf::from("/src/a.ts");
1134 let path_b = PathBuf::from("/src/b.ts");
1135 let changed: FxHashSet<PathBuf> = [path_a.clone(), path_b.clone()].into_iter().collect();
1136 let mut findings = vec![make_inline_finding(path_a), make_inline_finding(path_b)];
1137
1138 filter_inline_complexity_by_changed_files(&mut findings, &changed);
1139
1140 assert_eq!(
1141 findings.len(),
1142 2,
1143 "all findings in the changed set must be retained"
1144 );
1145 }
1146
1147 #[test]
1148 fn editor_session_applies_per_path_rule_overrides() {
1149 let temp = tempfile::tempdir().expect("temp project");
1153 let root = temp.path();
1154 std::fs::create_dir_all(root.join("src/ui")).expect("ui dir");
1155 std::fs::create_dir_all(root.join("src/lib")).expect("lib dir");
1156 std::fs::write(
1157 root.join("package.json"),
1158 r#"{"name":"editor-override-rules","private":true,"main":"src/index.ts"}"#,
1159 )
1160 .expect("package.json");
1161 std::fs::write(
1162 root.join(".fallowrc.json"),
1163 r#"{
1164 "rules": { "unused-exports": "warn", "private-type-leaks": "warn" },
1165 "overrides": [
1166 {
1167 "files": ["src/ui/**"],
1168 "rules": { "unused-exports": "off", "private-type-leaks": "off" }
1169 }
1170 ]
1171}"#,
1172 )
1173 .expect("config");
1174 std::fs::write(
1175 root.join("src/index.ts"),
1176 "import { kitUsed } from './ui/kit';\nimport { libUsed } from './lib/util';\n\nexport const app = `${kitUsed}${libUsed}`;\n",
1177 )
1178 .expect("index");
1179 std::fs::write(
1180 root.join("src/ui/kit.ts"),
1181 "type Props = { label: string };\n\nexport const kitUsed = 'kit';\n\nexport const Unused = (props: Props) => props.label;\n",
1182 )
1183 .expect("kit");
1184 std::fs::write(
1185 root.join("src/lib/util.ts"),
1186 "type Internal = { id: string };\n\nexport const libUsed = 'lib';\n\nexport const alsoUnused = (value: Internal) => value.id;\n",
1187 )
1188 .expect("util");
1189
1190 let session = EditorAnalysisSession::load(root, None).expect("session loads");
1191 let output = session
1192 .analyze_project_with_changed_files(
1193 &fallow_config::DuplicatesConfig::default(),
1194 false,
1195 None,
1196 )
1197 .expect("analysis runs");
1198 let results = &output.dead_code.results;
1199
1200 let unused_export_paths = || {
1201 results
1202 .unused_exports
1203 .iter()
1204 .map(|finding| finding.export.path.clone())
1205 .collect::<Vec<_>>()
1206 };
1207 let leak_paths = || {
1208 results
1209 .private_type_leaks
1210 .iter()
1211 .map(|finding| finding.leak.path.clone())
1212 .collect::<Vec<_>>()
1213 };
1214
1215 assert!(
1216 !unused_export_paths()
1217 .iter()
1218 .any(|path| path.ends_with("kit.ts")),
1219 "the override turns unused-exports off for src/ui/**: {:?}",
1220 unused_export_paths()
1221 );
1222 assert!(
1223 !leak_paths().iter().any(|path| path.ends_with("kit.ts")),
1224 "the override turns private-type-leaks off for src/ui/**: {:?}",
1225 leak_paths()
1226 );
1227 assert!(
1228 unused_export_paths()
1229 .iter()
1230 .any(|path| path.ends_with("util.ts")),
1231 "paths outside the override keep their unused export: {:?}",
1232 unused_export_paths()
1233 );
1234 assert!(
1235 leak_paths().iter().any(|path| path.ends_with("util.ts")),
1236 "paths outside the override keep their private type leak: {:?}",
1237 leak_paths()
1238 );
1239 }
1240
1241 #[test]
1242 fn merge_results_covers_all_fields() {
1243 let mut output = EditorAnalysisOutput::default();
1244
1245 output.merge_results(merge_test_source_with_all_fields());
1246
1247 let target = &output.results;
1248
1249 assert_eq!(target.unused_files.len(), 1);
1250 assert_eq!(target.unused_exports.len(), 1);
1251 assert_eq!(target.unused_types.len(), 1);
1252 assert_eq!(target.private_type_leaks.len(), 1);
1253 assert_eq!(target.deprecated_exports_in_use.len(), 1);
1254 assert_eq!(target.unused_dependencies.len(), 1);
1255 assert_eq!(target.unused_dev_dependencies.len(), 1);
1256 assert_eq!(target.unused_optional_dependencies.len(), 1);
1257 assert_eq!(target.unused_enum_members.len(), 1);
1258 assert_eq!(target.unused_class_members.len(), 1);
1259 assert_eq!(target.unused_store_members.len(), 1);
1260 assert_eq!(target.unresolved_imports.len(), 1);
1261 assert_eq!(target.unlisted_dependencies.len(), 1);
1262 assert_eq!(target.duplicate_exports.len(), 1);
1263 assert_eq!(target.type_only_dependencies.len(), 1);
1264 assert_eq!(target.test_only_dependencies.len(), 1);
1265 assert_eq!(target.circular_dependencies.len(), 1);
1266 assert_eq!(target.re_export_cycles.len(), 1);
1267 assert_eq!(target.package_cycles.len(), 1);
1268 assert_eq!(target.boundary_violations.len(), 1);
1269 assert_eq!(target.boundary_call_violations.len(), 1);
1270 assert_eq!(target.policy_violations.len(), 1);
1271 assert_eq!(target.stale_suppressions.len(), 1);
1272 assert_eq!(target.unused_catalog_entries.len(), 1);
1273 assert_eq!(target.empty_catalog_groups.len(), 1);
1274 assert_eq!(target.unresolved_catalog_references.len(), 1);
1275 assert_eq!(target.unused_dependency_overrides.len(), 1);
1276 assert_eq!(target.misconfigured_dependency_overrides.len(), 1);
1277 assert_eq!(target.invalid_client_exports.len(), 1);
1278 assert_eq!(target.mixed_client_server_barrels.len(), 1);
1279 assert_eq!(target.misplaced_directives.len(), 1);
1280 assert_eq!(target.export_usages.len(), 1);
1281 assert_eq!(target.feature_flags.len(), 1);
1282 assert_eq!(target.security_findings.len(), 1);
1283 assert_eq!(target.security_unresolved_edge_files, 2);
1284 assert_eq!(target.security_unresolved_callee_diagnostics.len(), 1);
1285 assert_eq!(target.suppression_count, 1);
1286 assert_eq!(target.cascade_hidden, 1);
1287 assert_eq!(target.cascade.suppressed_unused_files.len(), 1);
1288 assert!(target.entry_point_summary.is_some());
1289 assert_eq!(
1290 target
1291 .render_fan_in
1292 .as_ref()
1293 .and_then(|m| m.max_distinct_parents),
1294 Some(3)
1295 );
1296 assert_eq!(target.react_component_intel.len(), 1);
1297 assert_eq!(target.dev_dependencies_in_production.len(), 1);
1298 assert_eq!(target.boundary_coverage_violations.len(), 1);
1299 assert_eq!(target.route_collisions.len(), 1);
1300 assert_eq!(target.dynamic_segment_name_conflicts.len(), 1);
1301 assert_eq!(target.unprovided_injects.len(), 1);
1302 assert_eq!(target.unrendered_components.len(), 1);
1303 assert_eq!(target.unused_component_props.len(), 1);
1304 let candidate = &target.absent_component_props[0];
1305 assert_eq!(candidate.prop.component_name, "Widget");
1306 assert_eq!(candidate.prop.prop_name, "highlight");
1307 assert!(candidate.prop.has_default);
1308 assert_eq!(candidate.prop.framework, "vue");
1309 assert_eq!(
1310 candidate.prop.inspected_call_sites[0].path,
1311 PathBuf::from("/main.vue")
1312 );
1313 assert_eq!(candidate.prop.explanation, "Review optional prop contract");
1314
1315 assert_eq!(target.unused_component_emits.len(), 1);
1316 assert_eq!(target.unused_component_inputs.len(), 1);
1317 assert_eq!(target.unused_component_outputs.len(), 1);
1318 assert_eq!(target.unused_svelte_events.len(), 1);
1319 assert_eq!(target.unused_server_actions.len(), 1);
1320 assert_eq!(target.unused_load_data_keys.len(), 1);
1321 assert!(target.unused_load_data_keys_global_abstain);
1322 assert_eq!(target.prop_drilling_chains.len(), 1);
1323 assert_eq!(target.thin_wrappers.len(), 1);
1324 assert_eq!(target.duplicate_prop_shapes.len(), 1);
1325 assert_eq!(target.active_suppressions.len(), 1);
1326 assert_eq!(target.semantic_framework_contracts.len(), 1);
1327 assert_eq!(target.security_unresolved_callee_sites, 3);
1328 assert_eq!(target.unused_component_props_exempted, 1);
1329 }
1330
1331 #[test]
1332 fn merge_duplication_recomputes_percentage() {
1333 let target = EditorDuplicationReport {
1334 clone_groups: vec![],
1335 clone_families: vec![],
1336 mirrored_directories: vec![],
1337 stats: DuplicationStats {
1338 total_files: 5,
1339 files_with_clones: 1,
1340 total_lines: 200,
1341 duplicated_lines: 20,
1342 total_tokens: 1000,
1343 duplicated_tokens: 100,
1344 clone_groups: 1,
1345 clone_families: 0,
1346 clone_instances: 2,
1347 duplication_percentage: 10.0, clone_groups_below_min_occurrences: 0,
1349 clone_groups_ignored: 0,
1350 near_candidates_skipped: 0,
1351 },
1352 };
1353 let source = EditorDuplicationReport {
1354 clone_groups: vec![],
1355 clone_families: vec![],
1356 mirrored_directories: vec![],
1357 stats: DuplicationStats {
1358 total_files: 3,
1359 files_with_clones: 1,
1360 total_lines: 300,
1361 duplicated_lines: 60,
1362 total_tokens: 1500,
1363 duplicated_tokens: 300,
1364 clone_groups: 2,
1365 clone_families: 0,
1366 clone_instances: 4,
1367 duplication_percentage: 20.0, clone_groups_below_min_occurrences: 0,
1369 clone_groups_ignored: 0,
1370 near_candidates_skipped: 0,
1371 },
1372 };
1373
1374 let mut output = EditorAnalysisOutput::new(EditorAnalysisResults::default(), target);
1375 output.merge_duplication(source);
1376
1377 let target = &output.duplication;
1378 assert_eq!(target.stats.total_files, 8);
1379 assert_eq!(target.stats.files_with_clones, 2);
1380 assert_eq!(target.stats.total_lines, 500);
1381 assert_eq!(target.stats.duplicated_lines, 80);
1382 assert_eq!(target.stats.total_tokens, 2500);
1383 assert_eq!(target.stats.duplicated_tokens, 400);
1384 assert_eq!(target.stats.clone_groups, 3);
1385 assert_eq!(target.stats.clone_instances, 6);
1386 assert!((target.stats.duplication_percentage - 16.0).abs() < f64::EPSILON);
1387 }
1388
1389 #[test]
1390 fn merge_duplication_zero_total_lines_yields_zero_percentage() {
1391 let mut output = EditorAnalysisOutput::default();
1392
1393 output.merge_duplication(EditorDuplicationReport::default());
1394
1395 let target = &output.duplication;
1396
1397 assert_eq!(target.stats.total_lines, 0);
1398 assert!((target.stats.duplication_percentage - 0.0).abs() < f64::EPSILON);
1399 }
1400
1401 fn merge_test_unused_export(
1402 path: &str,
1403 export_name: &str,
1404 is_type_only: bool,
1405 line: u32,
1406 ) -> UnusedExport {
1407 UnusedExport {
1408 path: path.into(),
1409 export_name: export_name.to_string(),
1410 is_type_only,
1411 line,
1412 col: 0,
1413 span_start: 0,
1414 is_re_export: false,
1415 deprecated: false,
1416 deprecated_reason: None,
1417 }
1418 }
1419
1420 fn merge_test_unused_dependency(
1421 package_name: &str,
1422 location: super::editor_results::DependencyLocation,
1423 line: u32,
1424 ) -> UnusedDependency {
1425 UnusedDependency {
1426 package_name: package_name.to_string(),
1427 location,
1428 path: "/pkg.json".into(),
1429 line,
1430 used_in_workspaces: Vec::new(),
1431 declared_and_imported_in: Vec::new(),
1432 }
1433 }
1434
1435 fn merge_test_unused_member(
1436 parent_name: &str,
1437 member_name: &str,
1438 kind: super::editor_extract::MemberKind,
1439 line: u32,
1440 ) -> UnusedMember {
1441 UnusedMember {
1442 path: "/f.ts".into(),
1443 parent_name: parent_name.to_string(),
1444 member_name: member_name.to_string(),
1445 kind,
1446 line,
1447 col: 0,
1448 }
1449 }
1450
1451 #[expect(
1452 clippy::too_many_lines,
1453 reason = "intentionally names every EditorAnalysisResults field (no ..Default::default()) so a new field is a compile error here; see #444"
1454 )]
1455 pub fn merge_test_source_with_all_fields() -> EditorAnalysisResults {
1456 EditorAnalysisResults {
1457 unused_files: vec![UnusedFileFinding::with_actions(UnusedFile {
1458 path: "/f.ts".into(),
1459 })],
1460 unused_exports: vec![UnusedExportFinding::with_actions(merge_test_unused_export(
1461 "/f.ts", "e", false, 1,
1462 ))],
1463 unused_types: vec![UnusedTypeFinding::with_actions(merge_test_unused_export(
1464 "/f.ts", "T", true, 2,
1465 ))],
1466 unused_dependencies: vec![UnusedDependencyFinding::with_actions(
1467 merge_test_unused_dependency(
1468 "dep",
1469 super::editor_results::DependencyLocation::Dependencies,
1470 3,
1471 ),
1472 )],
1473 unused_dev_dependencies: vec![UnusedDevDependencyFinding::with_actions(
1474 merge_test_unused_dependency(
1475 "dev-dep",
1476 super::editor_results::DependencyLocation::DevDependencies,
1477 4,
1478 ),
1479 )],
1480 unused_optional_dependencies: vec![UnusedOptionalDependencyFinding::with_actions(
1481 merge_test_unused_dependency(
1482 "opt-dep",
1483 super::editor_results::DependencyLocation::OptionalDependencies,
1484 5,
1485 ),
1486 )],
1487 unused_enum_members: vec![UnusedEnumMemberFinding::with_actions(
1488 merge_test_unused_member(
1489 "E",
1490 "A",
1491 super::editor_extract::MemberKind::EnumMember,
1492 6,
1493 ),
1494 )],
1495 unused_class_members: vec![UnusedClassMemberFinding::with_actions(
1496 merge_test_unused_member(
1497 "C",
1498 "m",
1499 super::editor_extract::MemberKind::ClassMethod,
1500 7,
1501 ),
1502 )],
1503 unused_store_members: vec![UnusedStoreMemberFinding::with_actions(
1504 merge_test_unused_member(
1505 "S",
1506 "a",
1507 super::editor_extract::MemberKind::StoreMember,
1508 7,
1509 ),
1510 )],
1511 unresolved_imports: vec![
1512 super::editor_results::UnresolvedImportFinding::with_actions(
1513 super::editor_results::UnresolvedImport {
1514 path: "/f.ts".into(),
1515 specifier: "./gone".to_string(),
1516 line: 8,
1517 col: 0,
1518 specifier_col: 10,
1519 },
1520 ),
1521 ],
1522 unlisted_dependencies: vec![UnlistedDependencyFinding::with_actions(
1523 UnlistedDependency {
1524 package_name: "unlisted".to_string(),
1525 imported_from: vec![],
1526 },
1527 )],
1528 duplicate_exports: vec![super::editor_results::DuplicateExportFinding::with_actions(
1529 super::editor_results::DuplicateExport {
1530 export_name: "dup".to_string(),
1531 locations: vec![],
1532 },
1533 )],
1534 type_only_dependencies: vec![
1535 super::editor_results::TypeOnlyDependencyFinding::with_actions(
1536 TypeOnlyDependency {
1537 package_name: "type-only".to_string(),
1538 path: "/pkg.json".into(),
1539 line: 9,
1540 },
1541 ),
1542 ],
1543 circular_dependencies: vec![CircularDependencyFinding::with_actions(
1544 CircularDependency {
1545 files: vec!["/a.ts".into(), "/b.ts".into()],
1546 length: 2,
1547 line: 10,
1548 col: 0,
1549 edges: Vec::new(),
1550 is_cross_package: false,
1551 },
1552 )],
1553 test_only_dependencies: vec![TestOnlyDependencyFinding::with_actions(
1554 TestOnlyDependency {
1555 package_name: "test-only".to_string(),
1556 path: "/pkg.json".into(),
1557 line: 11,
1558 },
1559 )],
1560 dev_dependencies_in_production: vec![DevDependencyInProductionFinding::with_actions(
1561 DevDependencyInProduction {
1562 package_name: "dev-in-prod".to_string(),
1563 path: "/pkg.json".into(),
1564 line: 12,
1565 },
1566 )],
1567 boundary_violations: vec![BoundaryViolationFinding::with_actions(BoundaryViolation {
1568 from_path: "/a.ts".into(),
1569 to_path: "/b.ts".into(),
1570 from_zone: "ui".to_string(),
1571 to_zone: "data".to_string(),
1572 import_specifier: "../data/db".to_string(),
1573 line: 12,
1574 col: 0,
1575 via_path: None,
1576 })],
1577 boundary_coverage_violations: vec![
1578 super::editor_results::BoundaryCoverageViolationFinding::with_actions(
1579 super::editor_results::BoundaryCoverageViolation {
1580 path: "/unzoned.ts".into(),
1581 line: 13,
1582 col: 0,
1583 },
1584 ),
1585 ],
1586 boundary_call_violations: vec![
1587 super::editor_results::BoundaryCallViolationFinding::with_actions(
1588 super::editor_results::BoundaryCallViolation {
1589 path: "/zoned.ts".into(),
1590 line: 14,
1591 col: 0,
1592 zone: "domain".to_string(),
1593 callee: "console.log".to_string(),
1594 pattern: "console.*".to_string(),
1595 },
1596 ),
1597 ],
1598 policy_violations: vec![super::editor_results::PolicyViolationFinding::with_actions(
1599 super::editor_results::PolicyViolation {
1600 path: "/zoned.ts".into(),
1601 line: 15,
1602 col: 0,
1603 pack: "team-policy".to_string(),
1604 rule_id: "no-console".to_string(),
1605 kind: super::editor_results::PolicyRuleKind::BannedCall,
1606 matched: "console.log".to_string(),
1607 severity: super::editor_results::PolicyViolationSeverity::Warn,
1608 message: None,
1609 },
1610 )],
1611 export_usages: vec![ExportUsage {
1612 path: "/f.ts".into(),
1613 export_name: "used".to_string(),
1614 line: 15,
1615 col: 0,
1616 reference_count: 3,
1617 reference_locations: vec![],
1618 }],
1619 private_type_leaks: vec![super::editor_results::PrivateTypeLeakFinding::with_actions(
1620 super::editor_results::PrivateTypeLeak {
1621 path: "/f.ts".into(),
1622 export_name: "pub_fn".to_string(),
1623 type_name: "Secret".to_string(),
1624 line: 14,
1625 col: 0,
1626 span_start: 0,
1627 semantic: None,
1628 },
1629 )],
1630 deprecated_exports_in_use: vec![
1631 super::editor_results::DeprecatedExportInUseFinding::with_actions(
1632 super::editor_results::DeprecatedExportInUse {
1633 path: "/f.ts".into(),
1634 export_name: "old".to_string(),
1635 is_type_only: false,
1636 line: 16,
1637 col: 0,
1638 span_start: 0,
1639 deprecated_reason: None,
1640 consumer_count: 1,
1641 consumers: vec![super::editor_results::DeprecatedExportConsumer {
1642 path: "/g.ts".into(),
1643 line: 1,
1644 col: 0,
1645 kind: super::editor_results::DeprecatedConsumerKind::NamedImport,
1646 }],
1647 public_api: false,
1648 },
1649 ),
1650 ],
1651 re_export_cycles: vec![super::editor_results::ReExportCycleFinding::with_actions(
1652 super::editor_results::ReExportCycle {
1653 files: vec!["/barrel.ts".into()],
1654 kind: super::editor_results::ReExportCycleKind::SelfLoop,
1655 },
1656 )],
1657 package_cycles: vec![super::editor_results::PackageCycleFinding::with_actions(
1658 super::editor_results::PackageCycle {
1659 packages: vec!["a".into(), "b".into()],
1660 package_roots: Vec::new(),
1661 length: 2,
1662 edges: vec![super::editor_results::PackageCycleEdge {
1663 from_package: "a".into(),
1664 to_package: "b".into(),
1665 path: "/a/x.ts".into(),
1666 target_path: "/b/y.ts".into(),
1667 line: 1,
1668 col: 0,
1669 type_only: false,
1670 }],
1671 group_truncated: false,
1672 },
1673 )],
1674 stale_suppressions: vec![super::editor_results::StaleSuppression {
1675 finding_id: None,
1676 path: "/f.ts".into(),
1677 line: 15,
1678 col: 0,
1679 origin: super::editor_results::SuppressionOrigin::Comment {
1680 issue_kind: None,
1681 reason: None,
1682 is_file_level: false,
1683 kind_known: true,
1684 },
1685 missing_reason: false,
1686 actions: super::editor_results::StaleSuppression::actions_for(false),
1687 effective_severity: None,
1688 }],
1689 unused_catalog_entries: vec![
1690 super::editor_results::UnusedCatalogEntryFinding::with_actions(
1691 super::editor_results::UnusedCatalogEntry {
1692 entry_name: "react".to_string(),
1693 catalog_name: "default".to_string(),
1694 path: "/pnpm-workspace.yaml".into(),
1695 line: 16,
1696 hardcoded_consumers: vec![],
1697 },
1698 ),
1699 ],
1700 empty_catalog_groups: vec![
1701 super::editor_results::EmptyCatalogGroupFinding::with_actions(
1702 super::editor_results::EmptyCatalogGroup {
1703 catalog_name: "ui".to_string(),
1704 path: "/pnpm-workspace.yaml".into(),
1705 line: 17,
1706 },
1707 ),
1708 ],
1709 unresolved_catalog_references: vec![
1710 super::editor_results::UnresolvedCatalogReferenceFinding::with_actions(
1711 super::editor_results::UnresolvedCatalogReference {
1712 entry_name: "vue".to_string(),
1713 catalog_name: "default".to_string(),
1714 path: "/pkg.json".into(),
1715 line: 18,
1716 available_in_catalogs: vec![],
1717 },
1718 ),
1719 ],
1720 unused_dependency_overrides: vec![
1721 super::editor_results::UnusedDependencyOverrideFinding::with_actions(
1722 super::editor_results::UnusedDependencyOverride {
1723 raw_key: "react".to_string(),
1724 target_package: "react".to_string(),
1725 parent_package: None,
1726 version_constraint: None,
1727 version_range: "18".to_string(),
1728 source: super::editor_results::DependencyOverrideSource::PnpmWorkspaceYaml,
1729 path: "/pnpm-workspace.yaml".into(),
1730 line: 19,
1731 hint: None,
1732 },
1733 ),
1734 ],
1735 misconfigured_dependency_overrides: vec![
1736 super::editor_results::MisconfiguredDependencyOverrideFinding::with_actions(
1737 super::editor_results::MisconfiguredDependencyOverride {
1738 raw_key: "bad>".to_string(),
1739 target_package: None,
1740 raw_value: String::new(),
1741 reason:
1742 super::editor_results::DependencyOverrideMisconfigReason::EmptyValue,
1743 source: super::editor_results::DependencyOverrideSource::PnpmPackageJson,
1744 path: "/pkg.json".into(),
1745 line: 20,
1746 },
1747 ),
1748 ],
1749 invalid_client_exports: vec![
1750 super::editor_results::InvalidClientExportFinding::with_actions(
1751 super::editor_results::InvalidClientExport {
1752 path: "/app/page.tsx".into(),
1753 export_name: "metadata".to_string(),
1754 directive: "use client".to_string(),
1755 line: 22,
1756 col: 0,
1757 },
1758 ),
1759 ],
1760 mixed_client_server_barrels: vec![
1761 super::editor_results::MixedClientServerBarrelFinding::with_actions(
1762 super::editor_results::MixedClientServerBarrel {
1763 path: "/app/components/index.ts".into(),
1764 client_origin: "./Button".to_string(),
1765 server_origin: "./fetchUser".to_string(),
1766 line: 23,
1767 col: 0,
1768 },
1769 ),
1770 ],
1771 misplaced_directives: vec![
1772 super::editor_results::MisplacedDirectiveFinding::with_actions(
1773 super::editor_results::MisplacedDirective {
1774 path: "/app/widget.tsx".into(),
1775 directive: "use client".to_string(),
1776 line: 24,
1777 col: 0,
1778 },
1779 ),
1780 ],
1781 unprovided_injects: vec![
1782 super::editor_results::UnprovidedInjectFinding::with_actions(
1783 super::editor_results::UnprovidedInject {
1784 path: "/Comp.vue".into(),
1785 key_name: "ApiKey".to_string(),
1786 framework: "vue".to_string(),
1787 line: 25,
1788 col: 0,
1789 },
1790 ),
1791 ],
1792 unrendered_components: vec![
1793 super::editor_results::UnrenderedComponentFinding::with_actions(
1794 super::editor_results::UnrenderedComponent {
1795 path: "/Widget.vue".into(),
1796 component_name: "Widget".to_string(),
1797 framework: "vue".to_string(),
1798 reachable_via: None,
1799 line: 26,
1800 col: 0,
1801 },
1802 ),
1803 ],
1804 unused_component_props: vec![
1805 super::editor_results::UnusedComponentPropFinding::with_actions(
1806 super::editor_results::UnusedComponentProp {
1807 path: "/Widget.vue".into(),
1808 component_name: "Widget".to_string(),
1809 prop_name: "size".to_string(),
1810 line: 27,
1811 col: 0,
1812 },
1813 ),
1814 ],
1815 absent_component_props: vec![
1816 super::editor_results::AbsentComponentPropFinding::with_actions(
1817 super::editor_results::AbsentComponentProp {
1818 path: "/Widget.vue".into(),
1819 component_name: "Widget".to_string(),
1820 framework: "vue".to_string(),
1821 prop_name: "highlight".to_string(),
1822 line: 27,
1823 col: 0,
1824 has_default: true,
1825 inspected_call_sites: vec![super::editor_results::ComponentPropCallSite {
1826 path: "/main.vue".into(),
1827 line: 1,
1828 col: 0,
1829 }],
1830 explanation: "Review optional prop contract".to_string(),
1831 },
1832 ),
1833 ],
1834 unused_component_emits: vec![
1835 super::editor_results::UnusedComponentEmitFinding::with_actions(
1836 super::editor_results::UnusedComponentEmit {
1837 path: "/Widget.vue".into(),
1838 component_name: "Widget".to_string(),
1839 emit_name: "change".to_string(),
1840 line: 28,
1841 col: 0,
1842 },
1843 ),
1844 ],
1845 unused_component_inputs: vec![
1846 super::editor_results::UnusedComponentInputFinding::with_actions(
1847 super::editor_results::UnusedComponentInput {
1848 path: "/widget.component.ts".into(),
1849 component_name: "WidgetComponent".to_string(),
1850 input_name: "size".to_string(),
1851 line: 29,
1852 col: 0,
1853 },
1854 ),
1855 ],
1856 unused_component_outputs: vec![
1857 super::editor_results::UnusedComponentOutputFinding::with_actions(
1858 super::editor_results::UnusedComponentOutput {
1859 path: "/widget.component.ts".into(),
1860 component_name: "WidgetComponent".to_string(),
1861 output_name: "change".to_string(),
1862 line: 30,
1863 col: 0,
1864 },
1865 ),
1866 ],
1867 unused_svelte_events: vec![
1868 super::editor_results::UnusedSvelteEventFinding::with_actions(
1869 super::editor_results::UnusedSvelteEvent {
1870 path: "/Child.svelte".into(),
1871 component_name: "Child".to_string(),
1872 event_name: "dead".to_string(),
1873 line: 31,
1874 col: 0,
1875 },
1876 ),
1877 ],
1878 unused_server_actions: vec![
1879 super::editor_results::UnusedServerActionFinding::with_actions(
1880 super::editor_results::UnusedServerAction {
1881 path: "/app/actions.ts".into(),
1882 action_name: "createUser".to_string(),
1883 line: 32,
1884 col: 0,
1885 },
1886 ),
1887 ],
1888 unused_load_data_keys: vec![
1889 super::editor_results::UnusedLoadDataKeyFinding::with_actions(
1890 super::editor_results::UnusedLoadDataKey {
1891 path: "/src/routes/blog/+page.server.ts".into(),
1892 key_name: "posts".to_string(),
1893 line: 33,
1894 col: 0,
1895 route_dir: None,
1896 },
1897 ),
1898 ],
1899 unused_load_data_keys_global_abstain: true,
1900 prop_drilling_chains: vec![
1901 super::editor_results::PropDrillingChainFinding::with_actions(
1902 super::editor_results::PropDrillingChain {
1903 prop: "user".to_string(),
1904 depth: 1,
1905 hops: vec![super::editor_results::PropDrillHop {
1906 file: "/Hop.tsx".into(),
1907 line: 34,
1908 component: "Hop".to_string(),
1909 }],
1910 },
1911 ),
1912 ],
1913 thin_wrappers: vec![super::editor_results::ThinWrapperFinding::with_actions(
1914 super::editor_results::ThinWrapper {
1915 file: "/Wrapper.tsx".into(),
1916 line: 35,
1917 component: "Wrapper".to_string(),
1918 child_component: "Child".to_string(),
1919 },
1920 )],
1921 duplicate_prop_shapes: vec![
1922 super::editor_results::DuplicatePropShapeFinding::with_actions(
1923 super::editor_results::DuplicatePropShape {
1924 file: "/Card.tsx".into(),
1925 line: 36,
1926 component: "Card".to_string(),
1927 shape: vec!["subtitle".to_string(), "title".to_string()],
1928 group_size: 2,
1929 sharing_components: vec![],
1930 },
1931 ),
1932 ],
1933 route_collisions: vec![super::editor_results::RouteCollisionFinding::with_actions(
1934 super::editor_results::RouteCollision {
1935 path: "/app/(a)/about/page.tsx".into(),
1936 url: "/about".to_string(),
1937 conflicting_paths: vec!["/app/(b)/about/page.tsx".into()],
1938 line: 1,
1939 col: 0,
1940 },
1941 )],
1942 dynamic_segment_name_conflicts: vec![
1943 super::editor_results::DynamicSegmentNameConflictFinding::with_actions(
1944 super::editor_results::DynamicSegmentNameConflict {
1945 path: "/app/shop/[id]/page.tsx".into(),
1946 position: "/shop".to_string(),
1947 conflicting_segments: vec!["[id]".to_string(), "[slug]".to_string()],
1948 conflicting_paths: vec!["/app/shop/[slug]/edit/page.tsx".into()],
1949 line: 1,
1950 col: 0,
1951 },
1952 ),
1953 ],
1954 cascade_hidden: 1,
1955 cascade: fallow_types::results::CascadeState {
1956 suppressed_unused_files: vec!["/f.ts".into()],
1957 ..fallow_types::results::CascadeState::default()
1958 },
1959 suppression_count: 1,
1960 unused_component_props_exempted: 1,
1961 active_suppressions: vec![super::editor_results::ActiveSuppression {
1962 path: "/f.ts".into(),
1963 kind: Some("unused-export".to_string()),
1964 is_file_level: false,
1965 reason: None,
1966 comment_line: 37,
1967 }],
1968 feature_flags: vec![super::editor_results::FeatureFlag {
1969 path: "/f.ts".into(),
1970 flag_name: "ENABLE_X".to_string(),
1971 kind: super::editor_results::FlagKind::EnvironmentVariable,
1972 confidence: super::editor_results::FlagConfidence::High,
1973 line: 21,
1974 col: 0,
1975 guard_span_start: None,
1976 guard_span_end: None,
1977 sdk_name: None,
1978 guard_line_start: None,
1979 guard_line_end: None,
1980 guarded_dead_exports: vec![],
1981 }],
1982 entry_point_summary: Some(super::editor_results::EntryPointSummary {
1983 total: 0,
1984 by_source: vec![],
1985 }),
1986 security_findings: vec![super::editor_results::SecurityFinding {
1987 finding_id: String::new(),
1988 candidate: super::editor_results::SecurityCandidate::default(),
1989 taint_flow: None,
1990 attack_surface: None,
1991 kind: super::editor_results::SecurityFindingKind::ClientServerLeak,
1992 category: None,
1993 cwe: None,
1994 path: "/client.tsx".into(),
1995 line: 1,
1996 col: 0,
1997 evidence: "transitively reaches DATABASE_URL".to_string(),
1998 source_backed: false,
1999 source_read: None,
2000 severity: SecuritySeverity::Low,
2001 trace: vec![],
2002 actions: vec![],
2003 dead_code: None,
2004 reachability: None,
2005 runtime: None,
2006 }],
2007 security_unresolved_edge_files: 2,
2008 security_unresolved_callee_sites: 3,
2009 security_unresolved_callee_diagnostics: vec![
2010 super::editor_results::SecurityUnresolvedCalleeDiagnostic {
2011 path: "/client.tsx".into(),
2012 line: 2,
2013 col: 0,
2014 reason: super::editor_extract::SkippedSecurityCalleeReason::DynamicDispatch,
2015 expression_kind:
2016 super::editor_extract::SkippedSecurityCalleeExpressionKind::Other,
2017 },
2018 ],
2019 render_fan_in: Some(super::editor_results::RenderFanInMetric {
2020 per_component: vec![super::editor_results::RenderFanInComponent {
2021 file: "/Button.tsx".into(),
2022 component: "Button".to_string(),
2023 render_sites: 6,
2024 distinct_parents: 3,
2025 }],
2026 p95_distinct_parents: Some(3),
2027 high_pct: Some(0.0),
2028 max_distinct_parents: Some(3),
2029 }),
2030 react_component_intel: vec![super::editor_results::ReactComponentIntel {
2031 path: "/Button.tsx".into(),
2032 component_name: "Button".to_string(),
2033 anchor_line: 1,
2034 anchor_col: 0,
2035 render_sites: 6,
2036 distinct_parents: 3,
2037 prop_count: 1,
2038 hooks: super::editor_results::ReactHookSummary::default(),
2039 props: Vec::new(),
2040 }],
2041 semantic_framework_contracts: vec![fallow_types::semantic::SemanticFrameworkContract {
2042 framework: "lit".to_string(),
2043 package: "lit".to_string(),
2044 heritage_symbol: "LitElement".to_string(),
2045 heritage_names: vec!["LitElement".to_string()],
2046 relation: fallow_types::semantic::SemanticFrameworkRelation::Extends,
2047 members: vec!["render".to_string()],
2048 }],
2049 }
2050 }
2051}