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!(target.entry_point_summary.is_some());
1287 assert_eq!(
1288 target
1289 .render_fan_in
1290 .as_ref()
1291 .and_then(|m| m.max_distinct_parents),
1292 Some(3)
1293 );
1294 assert_eq!(target.react_component_intel.len(), 1);
1295 assert_eq!(target.dev_dependencies_in_production.len(), 1);
1296 assert_eq!(target.boundary_coverage_violations.len(), 1);
1297 assert_eq!(target.route_collisions.len(), 1);
1298 assert_eq!(target.dynamic_segment_name_conflicts.len(), 1);
1299 assert_eq!(target.unprovided_injects.len(), 1);
1300 assert_eq!(target.unrendered_components.len(), 1);
1301 assert_eq!(target.unused_component_props.len(), 1);
1302 let candidate = &target.absent_component_props[0];
1303 assert_eq!(candidate.prop.component_name, "Widget");
1304 assert_eq!(candidate.prop.prop_name, "highlight");
1305 assert!(candidate.prop.has_default);
1306 assert_eq!(candidate.prop.framework, "vue");
1307 assert_eq!(
1308 candidate.prop.inspected_call_sites[0].path,
1309 PathBuf::from("/main.vue")
1310 );
1311 assert_eq!(candidate.prop.explanation, "Review optional prop contract");
1312
1313 assert_eq!(target.unused_component_emits.len(), 1);
1314 assert_eq!(target.unused_component_inputs.len(), 1);
1315 assert_eq!(target.unused_component_outputs.len(), 1);
1316 assert_eq!(target.unused_svelte_events.len(), 1);
1317 assert_eq!(target.unused_server_actions.len(), 1);
1318 assert_eq!(target.unused_load_data_keys.len(), 1);
1319 assert!(target.unused_load_data_keys_global_abstain);
1320 assert_eq!(target.prop_drilling_chains.len(), 1);
1321 assert_eq!(target.thin_wrappers.len(), 1);
1322 assert_eq!(target.duplicate_prop_shapes.len(), 1);
1323 assert_eq!(target.active_suppressions.len(), 1);
1324 assert_eq!(target.semantic_framework_contracts.len(), 1);
1325 assert_eq!(target.security_unresolved_callee_sites, 3);
1326 assert_eq!(target.unused_component_props_exempted, 1);
1327 }
1328
1329 #[test]
1330 fn merge_duplication_recomputes_percentage() {
1331 let target = EditorDuplicationReport {
1332 clone_groups: vec![],
1333 clone_families: vec![],
1334 mirrored_directories: vec![],
1335 stats: DuplicationStats {
1336 total_files: 5,
1337 files_with_clones: 1,
1338 total_lines: 200,
1339 duplicated_lines: 20,
1340 total_tokens: 1000,
1341 duplicated_tokens: 100,
1342 clone_groups: 1,
1343 clone_families: 0,
1344 clone_instances: 2,
1345 duplication_percentage: 10.0, clone_groups_below_min_occurrences: 0,
1347 clone_groups_ignored: 0,
1348 near_candidates_skipped: 0,
1349 },
1350 };
1351 let source = EditorDuplicationReport {
1352 clone_groups: vec![],
1353 clone_families: vec![],
1354 mirrored_directories: vec![],
1355 stats: DuplicationStats {
1356 total_files: 3,
1357 files_with_clones: 1,
1358 total_lines: 300,
1359 duplicated_lines: 60,
1360 total_tokens: 1500,
1361 duplicated_tokens: 300,
1362 clone_groups: 2,
1363 clone_families: 0,
1364 clone_instances: 4,
1365 duplication_percentage: 20.0, clone_groups_below_min_occurrences: 0,
1367 clone_groups_ignored: 0,
1368 near_candidates_skipped: 0,
1369 },
1370 };
1371
1372 let mut output = EditorAnalysisOutput::new(EditorAnalysisResults::default(), target);
1373 output.merge_duplication(source);
1374
1375 let target = &output.duplication;
1376 assert_eq!(target.stats.total_files, 8);
1377 assert_eq!(target.stats.files_with_clones, 2);
1378 assert_eq!(target.stats.total_lines, 500);
1379 assert_eq!(target.stats.duplicated_lines, 80);
1380 assert_eq!(target.stats.total_tokens, 2500);
1381 assert_eq!(target.stats.duplicated_tokens, 400);
1382 assert_eq!(target.stats.clone_groups, 3);
1383 assert_eq!(target.stats.clone_instances, 6);
1384 assert!((target.stats.duplication_percentage - 16.0).abs() < f64::EPSILON);
1385 }
1386
1387 #[test]
1388 fn merge_duplication_zero_total_lines_yields_zero_percentage() {
1389 let mut output = EditorAnalysisOutput::default();
1390
1391 output.merge_duplication(EditorDuplicationReport::default());
1392
1393 let target = &output.duplication;
1394
1395 assert_eq!(target.stats.total_lines, 0);
1396 assert!((target.stats.duplication_percentage - 0.0).abs() < f64::EPSILON);
1397 }
1398
1399 fn merge_test_unused_export(
1400 path: &str,
1401 export_name: &str,
1402 is_type_only: bool,
1403 line: u32,
1404 ) -> UnusedExport {
1405 UnusedExport {
1406 path: path.into(),
1407 export_name: export_name.to_string(),
1408 is_type_only,
1409 line,
1410 col: 0,
1411 span_start: 0,
1412 is_re_export: false,
1413 deprecated: false,
1414 deprecated_reason: None,
1415 }
1416 }
1417
1418 fn merge_test_unused_dependency(
1419 package_name: &str,
1420 location: super::editor_results::DependencyLocation,
1421 line: u32,
1422 ) -> UnusedDependency {
1423 UnusedDependency {
1424 package_name: package_name.to_string(),
1425 location,
1426 path: "/pkg.json".into(),
1427 line,
1428 used_in_workspaces: Vec::new(),
1429 declared_and_imported_in: Vec::new(),
1430 }
1431 }
1432
1433 fn merge_test_unused_member(
1434 parent_name: &str,
1435 member_name: &str,
1436 kind: super::editor_extract::MemberKind,
1437 line: u32,
1438 ) -> UnusedMember {
1439 UnusedMember {
1440 path: "/f.ts".into(),
1441 parent_name: parent_name.to_string(),
1442 member_name: member_name.to_string(),
1443 kind,
1444 line,
1445 col: 0,
1446 }
1447 }
1448
1449 #[expect(
1450 clippy::too_many_lines,
1451 reason = "intentionally names every EditorAnalysisResults field (no ..Default::default()) so a new field is a compile error here; see #444"
1452 )]
1453 pub fn merge_test_source_with_all_fields() -> EditorAnalysisResults {
1454 EditorAnalysisResults {
1455 unused_files: vec![UnusedFileFinding::with_actions(UnusedFile {
1456 path: "/f.ts".into(),
1457 })],
1458 unused_exports: vec![UnusedExportFinding::with_actions(merge_test_unused_export(
1459 "/f.ts", "e", false, 1,
1460 ))],
1461 unused_types: vec![UnusedTypeFinding::with_actions(merge_test_unused_export(
1462 "/f.ts", "T", true, 2,
1463 ))],
1464 unused_dependencies: vec![UnusedDependencyFinding::with_actions(
1465 merge_test_unused_dependency(
1466 "dep",
1467 super::editor_results::DependencyLocation::Dependencies,
1468 3,
1469 ),
1470 )],
1471 unused_dev_dependencies: vec![UnusedDevDependencyFinding::with_actions(
1472 merge_test_unused_dependency(
1473 "dev-dep",
1474 super::editor_results::DependencyLocation::DevDependencies,
1475 4,
1476 ),
1477 )],
1478 unused_optional_dependencies: vec![UnusedOptionalDependencyFinding::with_actions(
1479 merge_test_unused_dependency(
1480 "opt-dep",
1481 super::editor_results::DependencyLocation::OptionalDependencies,
1482 5,
1483 ),
1484 )],
1485 unused_enum_members: vec![UnusedEnumMemberFinding::with_actions(
1486 merge_test_unused_member(
1487 "E",
1488 "A",
1489 super::editor_extract::MemberKind::EnumMember,
1490 6,
1491 ),
1492 )],
1493 unused_class_members: vec![UnusedClassMemberFinding::with_actions(
1494 merge_test_unused_member(
1495 "C",
1496 "m",
1497 super::editor_extract::MemberKind::ClassMethod,
1498 7,
1499 ),
1500 )],
1501 unused_store_members: vec![UnusedStoreMemberFinding::with_actions(
1502 merge_test_unused_member(
1503 "S",
1504 "a",
1505 super::editor_extract::MemberKind::StoreMember,
1506 7,
1507 ),
1508 )],
1509 unresolved_imports: vec![
1510 super::editor_results::UnresolvedImportFinding::with_actions(
1511 super::editor_results::UnresolvedImport {
1512 path: "/f.ts".into(),
1513 specifier: "./gone".to_string(),
1514 line: 8,
1515 col: 0,
1516 specifier_col: 10,
1517 },
1518 ),
1519 ],
1520 unlisted_dependencies: vec![UnlistedDependencyFinding::with_actions(
1521 UnlistedDependency {
1522 package_name: "unlisted".to_string(),
1523 imported_from: vec![],
1524 },
1525 )],
1526 duplicate_exports: vec![super::editor_results::DuplicateExportFinding::with_actions(
1527 super::editor_results::DuplicateExport {
1528 export_name: "dup".to_string(),
1529 locations: vec![],
1530 },
1531 )],
1532 type_only_dependencies: vec![
1533 super::editor_results::TypeOnlyDependencyFinding::with_actions(
1534 TypeOnlyDependency {
1535 package_name: "type-only".to_string(),
1536 path: "/pkg.json".into(),
1537 line: 9,
1538 },
1539 ),
1540 ],
1541 circular_dependencies: vec![CircularDependencyFinding::with_actions(
1542 CircularDependency {
1543 files: vec!["/a.ts".into(), "/b.ts".into()],
1544 length: 2,
1545 line: 10,
1546 col: 0,
1547 edges: Vec::new(),
1548 is_cross_package: false,
1549 },
1550 )],
1551 test_only_dependencies: vec![TestOnlyDependencyFinding::with_actions(
1552 TestOnlyDependency {
1553 package_name: "test-only".to_string(),
1554 path: "/pkg.json".into(),
1555 line: 11,
1556 },
1557 )],
1558 dev_dependencies_in_production: vec![DevDependencyInProductionFinding::with_actions(
1559 DevDependencyInProduction {
1560 package_name: "dev-in-prod".to_string(),
1561 path: "/pkg.json".into(),
1562 line: 12,
1563 },
1564 )],
1565 boundary_violations: vec![BoundaryViolationFinding::with_actions(BoundaryViolation {
1566 from_path: "/a.ts".into(),
1567 to_path: "/b.ts".into(),
1568 from_zone: "ui".to_string(),
1569 to_zone: "data".to_string(),
1570 import_specifier: "../data/db".to_string(),
1571 line: 12,
1572 col: 0,
1573 via_path: None,
1574 })],
1575 boundary_coverage_violations: vec![
1576 super::editor_results::BoundaryCoverageViolationFinding::with_actions(
1577 super::editor_results::BoundaryCoverageViolation {
1578 path: "/unzoned.ts".into(),
1579 line: 13,
1580 col: 0,
1581 },
1582 ),
1583 ],
1584 boundary_call_violations: vec![
1585 super::editor_results::BoundaryCallViolationFinding::with_actions(
1586 super::editor_results::BoundaryCallViolation {
1587 path: "/zoned.ts".into(),
1588 line: 14,
1589 col: 0,
1590 zone: "domain".to_string(),
1591 callee: "console.log".to_string(),
1592 pattern: "console.*".to_string(),
1593 },
1594 ),
1595 ],
1596 policy_violations: vec![super::editor_results::PolicyViolationFinding::with_actions(
1597 super::editor_results::PolicyViolation {
1598 path: "/zoned.ts".into(),
1599 line: 15,
1600 col: 0,
1601 pack: "team-policy".to_string(),
1602 rule_id: "no-console".to_string(),
1603 kind: super::editor_results::PolicyRuleKind::BannedCall,
1604 matched: "console.log".to_string(),
1605 severity: super::editor_results::PolicyViolationSeverity::Warn,
1606 message: None,
1607 },
1608 )],
1609 export_usages: vec![ExportUsage {
1610 path: "/f.ts".into(),
1611 export_name: "used".to_string(),
1612 line: 15,
1613 col: 0,
1614 reference_count: 3,
1615 reference_locations: vec![],
1616 }],
1617 private_type_leaks: vec![super::editor_results::PrivateTypeLeakFinding::with_actions(
1618 super::editor_results::PrivateTypeLeak {
1619 path: "/f.ts".into(),
1620 export_name: "pub_fn".to_string(),
1621 type_name: "Secret".to_string(),
1622 line: 14,
1623 col: 0,
1624 span_start: 0,
1625 semantic: None,
1626 },
1627 )],
1628 deprecated_exports_in_use: vec![
1629 super::editor_results::DeprecatedExportInUseFinding::with_actions(
1630 super::editor_results::DeprecatedExportInUse {
1631 path: "/f.ts".into(),
1632 export_name: "old".to_string(),
1633 is_type_only: false,
1634 line: 16,
1635 col: 0,
1636 span_start: 0,
1637 deprecated_reason: None,
1638 consumer_count: 1,
1639 consumers: vec![super::editor_results::DeprecatedExportConsumer {
1640 path: "/g.ts".into(),
1641 line: 1,
1642 col: 0,
1643 kind: super::editor_results::DeprecatedConsumerKind::NamedImport,
1644 }],
1645 public_api: false,
1646 },
1647 ),
1648 ],
1649 re_export_cycles: vec![super::editor_results::ReExportCycleFinding::with_actions(
1650 super::editor_results::ReExportCycle {
1651 files: vec!["/barrel.ts".into()],
1652 kind: super::editor_results::ReExportCycleKind::SelfLoop,
1653 },
1654 )],
1655 package_cycles: vec![super::editor_results::PackageCycleFinding::with_actions(
1656 super::editor_results::PackageCycle {
1657 packages: vec!["a".into(), "b".into()],
1658 package_roots: Vec::new(),
1659 length: 2,
1660 edges: vec![super::editor_results::PackageCycleEdge {
1661 from_package: "a".into(),
1662 to_package: "b".into(),
1663 path: "/a/x.ts".into(),
1664 target_path: "/b/y.ts".into(),
1665 line: 1,
1666 col: 0,
1667 type_only: false,
1668 }],
1669 group_truncated: false,
1670 },
1671 )],
1672 stale_suppressions: vec![super::editor_results::StaleSuppression {
1673 finding_id: None,
1674 path: "/f.ts".into(),
1675 line: 15,
1676 col: 0,
1677 origin: super::editor_results::SuppressionOrigin::Comment {
1678 issue_kind: None,
1679 reason: None,
1680 is_file_level: false,
1681 kind_known: true,
1682 },
1683 missing_reason: false,
1684 actions: super::editor_results::StaleSuppression::actions_for(false),
1685 effective_severity: None,
1686 }],
1687 unused_catalog_entries: vec![
1688 super::editor_results::UnusedCatalogEntryFinding::with_actions(
1689 super::editor_results::UnusedCatalogEntry {
1690 entry_name: "react".to_string(),
1691 catalog_name: "default".to_string(),
1692 path: "/pnpm-workspace.yaml".into(),
1693 line: 16,
1694 hardcoded_consumers: vec![],
1695 },
1696 ),
1697 ],
1698 empty_catalog_groups: vec![
1699 super::editor_results::EmptyCatalogGroupFinding::with_actions(
1700 super::editor_results::EmptyCatalogGroup {
1701 catalog_name: "ui".to_string(),
1702 path: "/pnpm-workspace.yaml".into(),
1703 line: 17,
1704 },
1705 ),
1706 ],
1707 unresolved_catalog_references: vec![
1708 super::editor_results::UnresolvedCatalogReferenceFinding::with_actions(
1709 super::editor_results::UnresolvedCatalogReference {
1710 entry_name: "vue".to_string(),
1711 catalog_name: "default".to_string(),
1712 path: "/pkg.json".into(),
1713 line: 18,
1714 available_in_catalogs: vec![],
1715 },
1716 ),
1717 ],
1718 unused_dependency_overrides: vec![
1719 super::editor_results::UnusedDependencyOverrideFinding::with_actions(
1720 super::editor_results::UnusedDependencyOverride {
1721 raw_key: "react".to_string(),
1722 target_package: "react".to_string(),
1723 parent_package: None,
1724 version_constraint: None,
1725 version_range: "18".to_string(),
1726 source: super::editor_results::DependencyOverrideSource::PnpmWorkspaceYaml,
1727 path: "/pnpm-workspace.yaml".into(),
1728 line: 19,
1729 hint: None,
1730 },
1731 ),
1732 ],
1733 misconfigured_dependency_overrides: vec![
1734 super::editor_results::MisconfiguredDependencyOverrideFinding::with_actions(
1735 super::editor_results::MisconfiguredDependencyOverride {
1736 raw_key: "bad>".to_string(),
1737 target_package: None,
1738 raw_value: String::new(),
1739 reason:
1740 super::editor_results::DependencyOverrideMisconfigReason::EmptyValue,
1741 source: super::editor_results::DependencyOverrideSource::PnpmPackageJson,
1742 path: "/pkg.json".into(),
1743 line: 20,
1744 },
1745 ),
1746 ],
1747 invalid_client_exports: vec![
1748 super::editor_results::InvalidClientExportFinding::with_actions(
1749 super::editor_results::InvalidClientExport {
1750 path: "/app/page.tsx".into(),
1751 export_name: "metadata".to_string(),
1752 directive: "use client".to_string(),
1753 line: 22,
1754 col: 0,
1755 },
1756 ),
1757 ],
1758 mixed_client_server_barrels: vec![
1759 super::editor_results::MixedClientServerBarrelFinding::with_actions(
1760 super::editor_results::MixedClientServerBarrel {
1761 path: "/app/components/index.ts".into(),
1762 client_origin: "./Button".to_string(),
1763 server_origin: "./fetchUser".to_string(),
1764 line: 23,
1765 col: 0,
1766 },
1767 ),
1768 ],
1769 misplaced_directives: vec![
1770 super::editor_results::MisplacedDirectiveFinding::with_actions(
1771 super::editor_results::MisplacedDirective {
1772 path: "/app/widget.tsx".into(),
1773 directive: "use client".to_string(),
1774 line: 24,
1775 col: 0,
1776 },
1777 ),
1778 ],
1779 unprovided_injects: vec![
1780 super::editor_results::UnprovidedInjectFinding::with_actions(
1781 super::editor_results::UnprovidedInject {
1782 path: "/Comp.vue".into(),
1783 key_name: "ApiKey".to_string(),
1784 framework: "vue".to_string(),
1785 line: 25,
1786 col: 0,
1787 },
1788 ),
1789 ],
1790 unrendered_components: vec![
1791 super::editor_results::UnrenderedComponentFinding::with_actions(
1792 super::editor_results::UnrenderedComponent {
1793 path: "/Widget.vue".into(),
1794 component_name: "Widget".to_string(),
1795 framework: "vue".to_string(),
1796 reachable_via: None,
1797 line: 26,
1798 col: 0,
1799 },
1800 ),
1801 ],
1802 unused_component_props: vec![
1803 super::editor_results::UnusedComponentPropFinding::with_actions(
1804 super::editor_results::UnusedComponentProp {
1805 path: "/Widget.vue".into(),
1806 component_name: "Widget".to_string(),
1807 prop_name: "size".to_string(),
1808 line: 27,
1809 col: 0,
1810 },
1811 ),
1812 ],
1813 absent_component_props: vec![
1814 super::editor_results::AbsentComponentPropFinding::with_actions(
1815 super::editor_results::AbsentComponentProp {
1816 path: "/Widget.vue".into(),
1817 component_name: "Widget".to_string(),
1818 framework: "vue".to_string(),
1819 prop_name: "highlight".to_string(),
1820 line: 27,
1821 col: 0,
1822 has_default: true,
1823 inspected_call_sites: vec![super::editor_results::ComponentPropCallSite {
1824 path: "/main.vue".into(),
1825 line: 1,
1826 col: 0,
1827 }],
1828 explanation: "Review optional prop contract".to_string(),
1829 },
1830 ),
1831 ],
1832 unused_component_emits: vec![
1833 super::editor_results::UnusedComponentEmitFinding::with_actions(
1834 super::editor_results::UnusedComponentEmit {
1835 path: "/Widget.vue".into(),
1836 component_name: "Widget".to_string(),
1837 emit_name: "change".to_string(),
1838 line: 28,
1839 col: 0,
1840 },
1841 ),
1842 ],
1843 unused_component_inputs: vec![
1844 super::editor_results::UnusedComponentInputFinding::with_actions(
1845 super::editor_results::UnusedComponentInput {
1846 path: "/widget.component.ts".into(),
1847 component_name: "WidgetComponent".to_string(),
1848 input_name: "size".to_string(),
1849 line: 29,
1850 col: 0,
1851 },
1852 ),
1853 ],
1854 unused_component_outputs: vec![
1855 super::editor_results::UnusedComponentOutputFinding::with_actions(
1856 super::editor_results::UnusedComponentOutput {
1857 path: "/widget.component.ts".into(),
1858 component_name: "WidgetComponent".to_string(),
1859 output_name: "change".to_string(),
1860 line: 30,
1861 col: 0,
1862 },
1863 ),
1864 ],
1865 unused_svelte_events: vec![
1866 super::editor_results::UnusedSvelteEventFinding::with_actions(
1867 super::editor_results::UnusedSvelteEvent {
1868 path: "/Child.svelte".into(),
1869 component_name: "Child".to_string(),
1870 event_name: "dead".to_string(),
1871 line: 31,
1872 col: 0,
1873 },
1874 ),
1875 ],
1876 unused_server_actions: vec![
1877 super::editor_results::UnusedServerActionFinding::with_actions(
1878 super::editor_results::UnusedServerAction {
1879 path: "/app/actions.ts".into(),
1880 action_name: "createUser".to_string(),
1881 line: 32,
1882 col: 0,
1883 },
1884 ),
1885 ],
1886 unused_load_data_keys: vec![
1887 super::editor_results::UnusedLoadDataKeyFinding::with_actions(
1888 super::editor_results::UnusedLoadDataKey {
1889 path: "/src/routes/blog/+page.server.ts".into(),
1890 key_name: "posts".to_string(),
1891 line: 33,
1892 col: 0,
1893 route_dir: None,
1894 },
1895 ),
1896 ],
1897 unused_load_data_keys_global_abstain: true,
1898 prop_drilling_chains: vec![
1899 super::editor_results::PropDrillingChainFinding::with_actions(
1900 super::editor_results::PropDrillingChain {
1901 prop: "user".to_string(),
1902 depth: 1,
1903 hops: vec![super::editor_results::PropDrillHop {
1904 file: "/Hop.tsx".into(),
1905 line: 34,
1906 component: "Hop".to_string(),
1907 }],
1908 },
1909 ),
1910 ],
1911 thin_wrappers: vec![super::editor_results::ThinWrapperFinding::with_actions(
1912 super::editor_results::ThinWrapper {
1913 file: "/Wrapper.tsx".into(),
1914 line: 35,
1915 component: "Wrapper".to_string(),
1916 child_component: "Child".to_string(),
1917 },
1918 )],
1919 duplicate_prop_shapes: vec![
1920 super::editor_results::DuplicatePropShapeFinding::with_actions(
1921 super::editor_results::DuplicatePropShape {
1922 file: "/Card.tsx".into(),
1923 line: 36,
1924 component: "Card".to_string(),
1925 shape: vec!["subtitle".to_string(), "title".to_string()],
1926 group_size: 2,
1927 sharing_components: vec![],
1928 },
1929 ),
1930 ],
1931 route_collisions: vec![super::editor_results::RouteCollisionFinding::with_actions(
1932 super::editor_results::RouteCollision {
1933 path: "/app/(a)/about/page.tsx".into(),
1934 url: "/about".to_string(),
1935 conflicting_paths: vec!["/app/(b)/about/page.tsx".into()],
1936 line: 1,
1937 col: 0,
1938 },
1939 )],
1940 dynamic_segment_name_conflicts: vec![
1941 super::editor_results::DynamicSegmentNameConflictFinding::with_actions(
1942 super::editor_results::DynamicSegmentNameConflict {
1943 path: "/app/shop/[id]/page.tsx".into(),
1944 position: "/shop".to_string(),
1945 conflicting_segments: vec!["[id]".to_string(), "[slug]".to_string()],
1946 conflicting_paths: vec!["/app/shop/[slug]/edit/page.tsx".into()],
1947 line: 1,
1948 col: 0,
1949 },
1950 ),
1951 ],
1952 suppression_count: 1,
1953 unused_component_props_exempted: 1,
1954 active_suppressions: vec![super::editor_results::ActiveSuppression {
1955 path: "/f.ts".into(),
1956 kind: Some("unused-export".to_string()),
1957 is_file_level: false,
1958 reason: None,
1959 comment_line: 37,
1960 }],
1961 feature_flags: vec![super::editor_results::FeatureFlag {
1962 path: "/f.ts".into(),
1963 flag_name: "ENABLE_X".to_string(),
1964 kind: super::editor_results::FlagKind::EnvironmentVariable,
1965 confidence: super::editor_results::FlagConfidence::High,
1966 line: 21,
1967 col: 0,
1968 guard_span_start: None,
1969 guard_span_end: None,
1970 sdk_name: None,
1971 guard_line_start: None,
1972 guard_line_end: None,
1973 guarded_dead_exports: vec![],
1974 }],
1975 entry_point_summary: Some(super::editor_results::EntryPointSummary {
1976 total: 0,
1977 by_source: vec![],
1978 }),
1979 security_findings: vec![super::editor_results::SecurityFinding {
1980 finding_id: String::new(),
1981 candidate: super::editor_results::SecurityCandidate::default(),
1982 taint_flow: None,
1983 attack_surface: None,
1984 kind: super::editor_results::SecurityFindingKind::ClientServerLeak,
1985 category: None,
1986 cwe: None,
1987 path: "/client.tsx".into(),
1988 line: 1,
1989 col: 0,
1990 evidence: "transitively reaches DATABASE_URL".to_string(),
1991 source_backed: false,
1992 source_read: None,
1993 severity: SecuritySeverity::Low,
1994 trace: vec![],
1995 actions: vec![],
1996 dead_code: None,
1997 reachability: None,
1998 runtime: None,
1999 }],
2000 security_unresolved_edge_files: 2,
2001 security_unresolved_callee_sites: 3,
2002 security_unresolved_callee_diagnostics: vec![
2003 super::editor_results::SecurityUnresolvedCalleeDiagnostic {
2004 path: "/client.tsx".into(),
2005 line: 2,
2006 col: 0,
2007 reason: super::editor_extract::SkippedSecurityCalleeReason::DynamicDispatch,
2008 expression_kind:
2009 super::editor_extract::SkippedSecurityCalleeExpressionKind::Other,
2010 },
2011 ],
2012 render_fan_in: Some(super::editor_results::RenderFanInMetric {
2013 per_component: vec![super::editor_results::RenderFanInComponent {
2014 file: "/Button.tsx".into(),
2015 component: "Button".to_string(),
2016 render_sites: 6,
2017 distinct_parents: 3,
2018 }],
2019 p95_distinct_parents: Some(3),
2020 high_pct: Some(0.0),
2021 max_distinct_parents: Some(3),
2022 }),
2023 react_component_intel: vec![super::editor_results::ReactComponentIntel {
2024 path: "/Button.tsx".into(),
2025 component_name: "Button".to_string(),
2026 anchor_line: 1,
2027 anchor_col: 0,
2028 render_sites: 6,
2029 distinct_parents: 3,
2030 prop_count: 1,
2031 hooks: super::editor_results::ReactHookSummary::default(),
2032 props: Vec::new(),
2033 }],
2034 semantic_framework_contracts: vec![fallow_types::semantic::SemanticFrameworkContract {
2035 framework: "lit".to_string(),
2036 package: "lit".to_string(),
2037 heritage_symbol: "LitElement".to_string(),
2038 heritage_names: vec!["LitElement".to_string()],
2039 relation: fallow_types::semantic::SemanticFrameworkRelation::Extends,
2040 members: vec!["render".to_string()],
2041 }],
2042 }
2043 }
2044}