Skip to main content

fallow_api/
editor.rs

1//! Editor-facing analysis contracts shared by LSP and future editor adapters.
2
3use std::path::{Path, PathBuf};
4
5use rustc_hash::FxHashSet;
6
7use fallow_types::{discover::DiscoveredFile, extract::ModuleInfo};
8
9/// Editor-boundary alias for the clone-family payload.
10pub type EditorCloneFamily = fallow_types::duplicates::CloneFamily;
11/// Editor-boundary alias for the clone-group payload.
12pub type EditorCloneGroup = fallow_types::duplicates::CloneGroup;
13/// Editor-boundary alias for one clone occurrence within a group.
14pub type EditorCloneInstance = fallow_types::duplicates::CloneInstance;
15/// Editor-boundary alias for the full duplication report.
16pub type EditorDuplicationReport = fallow_types::duplicates::DuplicationReport;
17/// Editor-boundary alias for aggregate duplication statistics.
18pub type EditorDuplicationStats = fallow_types::duplicates::DuplicationStats;
19/// Editor-boundary alias for a mirrored-directory finding.
20pub type EditorMirroredDirectory = fallow_types::duplicates::MirroredDirectory;
21/// Editor-boundary alias for the refactoring-suggestion kind.
22pub type EditorRefactoringKind = fallow_types::duplicates::RefactoringKind;
23/// Editor-boundary alias for a refactoring suggestion.
24pub type EditorRefactoringSuggestion = fallow_types::duplicates::RefactoringSuggestion;
25
26/// Report-scoped clone fingerprint assignment for editor-facing duplication output.
27#[derive(Debug, Clone)]
28pub struct EditorCloneFingerprintSet {
29    inner: fallow_engine::duplicates::CloneFingerprintSet,
30}
31
32impl EditorCloneFingerprintSet {
33    /// Assign collision-free fingerprints for clone groups in one report.
34    #[must_use]
35    pub fn from_groups(groups: &[EditorCloneGroup]) -> Self {
36        Self {
37            inner: fallow_engine::duplicates::CloneFingerprintSet::from_groups(groups),
38        }
39    }
40
41    /// Return the assigned fingerprint for a clone group.
42    #[must_use]
43    pub fn fingerprint_for_group(&self, group: &EditorCloneGroup) -> String {
44        self.inner.fingerprint_for_group(group)
45    }
46
47    /// Return the assigned fingerprint for clone-group parts.
48    #[must_use]
49    pub fn fingerprint_for_parts(
50        &self,
51        instances: &[EditorCloneInstance],
52        token_count: usize,
53        line_count: usize,
54    ) -> String {
55        self.inner
56            .fingerprint_for_parts(instances, token_count, line_count)
57    }
58
59    /// Find the group addressed by an assigned fingerprint.
60    #[must_use]
61    pub fn find_group<'a>(
62        &self,
63        groups: &'a [EditorCloneGroup],
64        fingerprint: &str,
65    ) -> Option<&'a EditorCloneGroup> {
66        self.inner.find_group(groups, fingerprint)
67    }
68}
69
70/// Duplication contracts re-exported under their unprefixed names so editor
71/// adapters can import them as a module namespace.
72pub mod editor_duplicates {
73    pub use crate::editor::{
74        EditorCloneFamily as CloneFamily, EditorCloneFingerprintSet as CloneFingerprintSet,
75        EditorCloneGroup as CloneGroup, EditorCloneInstance as CloneInstance,
76        EditorDuplicationReport as DuplicationReport, EditorDuplicationStats as DuplicationStats,
77        EditorMirroredDirectory as MirroredDirectory, EditorRefactoringKind as RefactoringKind,
78        EditorRefactoringSuggestion as RefactoringSuggestion,
79    };
80}
81
82/// Classification of a changed-file git failure for editor integrations.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub enum ChangedFilesError {
85    /// Git ref failed validation before invoking `git`.
86    InvalidRef(String),
87    /// `git` binary not found or not executable.
88    GitMissing(String),
89    /// Command ran but the directory is not a git repository.
90    NotARepository,
91    /// Command ran but the ref is invalid or another git error occurred.
92    GitFailed(String),
93}
94
95impl ChangedFilesError {
96    /// Human-readable clause suitable for embedding in an error message.
97    #[must_use]
98    pub fn describe(&self) -> String {
99        match self {
100            Self::InvalidRef(err) => format!("invalid git ref: {err}"),
101            Self::GitMissing(err) => format!("failed to run git: {err}"),
102            Self::NotARepository => "not a git repository".to_owned(),
103            Self::GitFailed(stderr) => {
104                let lower = stderr.to_ascii_lowercase();
105                if lower.contains("not a valid object name")
106                    || lower.contains("unknown revision")
107                    || lower.contains("ambiguous argument")
108                {
109                    format!(
110                        "{stderr} (shallow clone? try `git fetch --unshallow`, or set `fetch-depth: 0` on actions/checkout / `GIT_DEPTH: 0` in GitLab CI)"
111                    )
112                } else {
113                    stderr.clone()
114                }
115            }
116        }
117    }
118}
119
120impl From<fallow_engine::changed_files::ChangedFilesError> for ChangedFilesError {
121    fn from(error: fallow_engine::changed_files::ChangedFilesError) -> Self {
122        match error {
123            fallow_engine::changed_files::ChangedFilesError::InvalidRef(err) => {
124                Self::InvalidRef(err)
125            }
126            fallow_engine::changed_files::ChangedFilesError::GitMissing(err) => {
127                Self::GitMissing(err)
128            }
129            fallow_engine::changed_files::ChangedFilesError::NotARepository => Self::NotARepository,
130            fallow_engine::changed_files::ChangedFilesError::GitFailed(stderr) => {
131                Self::GitFailed(stderr)
132            }
133        }
134    }
135}
136
137/// Resolve the canonical git toplevel for `cwd`.
138///
139/// # Errors
140///
141/// Returns an API-owned changed-file error when git cannot inspect the
142/// repository.
143pub fn resolve_git_toplevel(cwd: &Path) -> Result<PathBuf, ChangedFilesError> {
144    fallow_engine::changed_files::resolve_git_toplevel(cwd).map_err(ChangedFilesError::from)
145}
146
147/// Get changed files and the git toplevel used to resolve them.
148///
149/// # Errors
150///
151/// Returns an API-owned changed-file error when git cannot resolve the ref or
152/// repository state.
153pub fn try_get_changed_files_with_toplevel(
154    cwd: &Path,
155    toplevel: &Path,
156    git_ref: &str,
157) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
158    fallow_engine::changed_files::try_get_changed_files_with_toplevel(cwd, toplevel, git_ref)
159        .map_err(ChangedFilesError::from)
160}
161
162/// Per-module extraction facts re-exported for editor adapters that inspect
163/// retained parse artifacts.
164pub mod editor_extract {
165    pub use fallow_types::extract::{
166        AngularComponentSelector, AngularInputMember, AngularOutputMember,
167        AngularTemplateMemberAccessFact, AngularThisSpreadFact, CalleeUse, ClassHeritageInfo,
168        ComplexityContribution, ComplexityContributionKind, ComplexityMetric, ComponentEmit,
169        ComponentFunction, ComponentFunctionKind, ComponentProp, CssAnalytics, CssDeclarationBlock,
170        CssRuleMetric, DiFramework, DiKeySite, DiRole, DispatchedEvent,
171        DynamicCustomElementRenderFact, DynamicImportInfo, DynamicImportPattern, ExportInfo,
172        ExportName, FactoryCallMemberAccessFact, FactoryFnMemberAccessFact, FactoryReturnExport,
173        FlagUse, FlagUseKind, FluentChainMemberAccessFact, FluentChainNewMemberAccessFact,
174        ForwardAttr, FunctionComplexity, HookUse, HookUseKind, ImportInfo, ImportedName,
175        InstanceExportBindingFact, LoadReturnKey, LocalTypeDeclaration, MemberAccess, MemberInfo,
176        MemberKind, MisplacedDirectiveSite, ModuleInfo, NamespaceObjectAlias, PUBLIC_ENV_EXACT,
177        PUBLIC_ENV_METADATA_TOKENS, PUBLIC_ENV_PREFIXES, ParseResult, PlaywrightFixtureAliasFact,
178        PlaywrightFixtureDefinitionFact, PlaywrightFixtureTypeFact, PlaywrightFixtureUseFact,
179        PublicSignatureTypeReference, ReExportInfo, RegisteredCustomElement, RenderEdge,
180        RequireCallInfo, SECRET_ENV_TOKENS, SanitizedSinkArg, SanitizerScope, SecurityControlKind,
181        SecurityControlSite, SecurityUrlShape, SemanticFact, SemanticFactView, SinkArgKind,
182        SinkLiteralValue, SinkObjectProperty, SinkShape, SinkSite,
183        SkippedSecurityCalleeExpressionKind, SkippedSecurityCalleeReason,
184        SkippedSecurityCalleeSite, TaintedBinding, VisibilityTag,
185    };
186}
187
188/// Typed analysis-result and finding contracts re-exported for editor
189/// adapters.
190pub mod editor_results {
191    pub use fallow_types::output_dead_code::{
192        BoundaryCallViolationFinding, BoundaryCoverageViolationFinding, BoundaryViolationFinding,
193        CircularDependencyFinding, DevDependencyInProductionFinding, DuplicateExportFinding,
194        DuplicatePropShapeFinding, DynamicSegmentNameConflictFinding, EmptyCatalogGroupFinding,
195        InvalidClientExportFinding, MisconfiguredDependencyOverrideFinding,
196        MisplacedDirectiveFinding, MixedClientServerBarrelFinding, PolicyViolationFinding,
197        PrivateTypeLeakFinding, PropDrillingChainFinding, ReExportCycleFinding,
198        RouteCollisionFinding, TestOnlyDependencyFinding, ThinWrapperFinding,
199        TypeOnlyDependencyFinding, UnlistedDependencyFinding, UnprovidedInjectFinding,
200        UnrenderedComponentFinding, UnresolvedCatalogReferenceFinding, UnresolvedImportFinding,
201        UnusedCatalogEntryFinding, UnusedClassMemberFinding, UnusedComponentEmitFinding,
202        UnusedComponentInputFinding, UnusedComponentOutputFinding, UnusedComponentPropFinding,
203        UnusedDependencyFinding, UnusedDependencyOverrideFinding, UnusedDevDependencyFinding,
204        UnusedEnumMemberFinding, UnusedExportFinding, UnusedFileFinding, UnusedLoadDataKeyFinding,
205        UnusedOptionalDependencyFinding, UnusedServerActionFinding, UnusedStoreMemberFinding,
206        UnusedSvelteEventFinding, UnusedTypeFinding,
207    };
208    pub use fallow_types::results::{
209        ActiveSuppression, AnalysisResults, BoundaryCallViolation, BoundaryCoverageViolation,
210        BoundaryViolation, CircularDependency, CircularDependencyEdge, DependencyLocation,
211        DependencyOverrideMisconfigReason, DependencyOverrideSource, DevDependencyInProduction,
212        DuplicateExport, DuplicateLocation, DuplicatePropShape, DuplicatePropShapeMember,
213        DynamicSegmentNameConflict, EmptyCatalogGroup, EntryPointSummary, ExportUsage, FeatureFlag,
214        FlagConfidence, FlagKind, ImportSite, InvalidClientExport, MisconfiguredDependencyOverride,
215        MisplacedDirective, MixedClientServerBarrel, PolicyRuleKind, PolicyViolation,
216        PolicyViolationSeverity, PrivateTypeLeak, PropDrillHop, PropDrillingChain, ReExportCycle,
217        ReExportCycleKind, ReactComponentIntel, ReactHookSummary, ReactPropDrill, ReactPropIntel,
218        ReferenceLocation, RenderFanInComponent, RenderFanInMetric, RouteCollision,
219        SecurityAttackSurfaceEntry, SecurityCandidate, SecurityCandidateBoundary,
220        SecurityCandidateSink, SecurityDeadCodeContext, SecurityDeadCodeKind,
221        SecurityDefensiveBoundary, SecurityDefensiveControl, SecurityFinding, SecurityFindingKind,
222        SecurityNetworkContext, SecurityReachability, SecurityRuntimeContext, SecurityRuntimeState,
223        SecuritySeverity, SecurityTaintFlow, SecurityUnresolvedCalleeDiagnostic,
224        SecurityZoneCrossing, StaleSuppression, SuppressionOrigin, TaintConfidence, TaintEndpoint,
225        TaintPath, TestOnlyDependency, ThinWrapper, TraceHop, TraceHopRole, TypeOnlyDependency,
226        UnlistedDependency, UnprovidedInject, UnrenderedComponent, UnresolvedCatalogReference,
227        UnresolvedImport, UnusedCatalogEntry, UnusedComponentEmit, UnusedComponentInput,
228        UnusedComponentOutput, UnusedComponentProp, UnusedDependency, UnusedDependencyOverride,
229        UnusedExport, UnusedFile, UnusedLoadDataKey, UnusedMember, UnusedServerAction,
230        UnusedSvelteEvent,
231    };
232}
233
234/// Security catalogue lookups exposed at the editor boundary.
235pub mod editor_security {
236    /// Return the human-readable security catalogue title for a finding kind.
237    #[must_use]
238    pub fn security_catalogue_title(kind: &str) -> Option<&'static str> {
239        fallow_engine::dead_code::security_catalogue_title(kind)
240    }
241}
242
243/// Inline-suppression contracts re-exported for editor adapters.
244pub mod editor_suppress {
245    pub use fallow_types::suppress::{IssueKind, is_suppressed};
246}
247
248/// Editor-boundary alias for the typed dead-code analysis results.
249pub type EditorAnalysisResults = fallow_types::results::AnalysisResults;
250
251/// Dead-code output retained for editor integrations.
252///
253/// The engine produces the data, but the editor API owns this public contract
254/// so LSP and future editor adapters do not depend on engine result structs.
255#[derive(Debug)]
256pub struct EditorDeadCodeAnalysisOutput {
257    /// Typed dead-code findings from the analysis.
258    pub results: EditorAnalysisResults,
259    /// Retained per-module parse artifacts; `None` unless the run was asked
260    /// to keep them for follow-up editor features.
261    pub modules: Option<Vec<ModuleInfo>>,
262    /// Retained discovered-file records matching `modules`.
263    pub files: Option<Vec<DiscoveredFile>>,
264}
265
266impl EditorDeadCodeAnalysisOutput {
267    fn from_engine(output: fallow_engine::dead_code::DeadCodeAnalysisOutput) -> Self {
268        Self {
269            results: output.results,
270            modules: output.modules,
271            files: output.files,
272        }
273    }
274}
275
276/// Editor-facing inline complexity signal for code lens and similar surfaces.
277///
278/// The finding is derived from retained typed engine parse artifacts, but the
279/// editor API owns the stable shape so LSP and future editor adapters do not
280/// need to inspect raw modules directly.
281#[derive(Debug, Clone, PartialEq, Eq)]
282pub struct EditorInlineComplexityFinding {
283    /// Absolute path of the file declaring the function.
284    pub path: PathBuf,
285    /// Function name as extracted from the source.
286    pub name: String,
287    /// One-based line of the function declaration.
288    pub line: u32,
289    /// Zero-based column of the function declaration.
290    pub col: u32,
291    /// Measured cyclomatic complexity.
292    pub cyclomatic: u16,
293    /// Measured cognitive complexity.
294    pub cognitive: u16,
295    /// Which configured threshold(s) the function exceeded.
296    pub exceeded: EditorInlineComplexityExceeded,
297}
298
299/// Which health complexity threshold(s) a function exceeded.
300#[derive(Debug, Clone, Copy, PartialEq, Eq)]
301pub enum EditorInlineComplexityExceeded {
302    /// Only the cyclomatic threshold was exceeded.
303    Cyclomatic,
304    /// Only the cognitive threshold was exceeded.
305    Cognitive,
306    /// Both thresholds were exceeded.
307    CyclomaticAndCognitive,
308}
309
310/// Collect inline complexity findings from retained editor analysis artifacts.
311#[must_use]
312pub fn collect_inline_complexity(
313    config: &fallow_config::ResolvedConfig,
314    output: &EditorDeadCodeAnalysisOutput,
315) -> Vec<EditorInlineComplexityFinding> {
316    let Some(modules) = output.modules.as_ref() else {
317        return Vec::new();
318    };
319    let Some(files) = output.files.as_ref() else {
320        return Vec::new();
321    };
322
323    let file_paths: rustc_hash::FxHashMap<_, _> =
324        files.iter().map(|file| (file.id, &file.path)).collect();
325    let ignore_set = build_health_ignore_set(&config.health.ignore);
326    let mut findings = Vec::new();
327
328    for module in modules {
329        let Some(path) = file_paths.get(&module.file_id) else {
330            continue;
331        };
332        let relative = path.strip_prefix(&config.root).unwrap_or(path);
333        if ignore_set
334            .as_ref()
335            .is_some_and(|set| set.is_match(relative))
336        {
337            continue;
338        }
339
340        for function in &module.complexity {
341            // The module-scope unit is aggregate-only and never becomes a
342            // finding, so it gets no code lens either: a permanent lens at the
343            // top of every branching file is noise, not a refactoring cue.
344            if fallow_types::extract::is_synthetic_module_unit(&function.name) {
345                continue;
346            }
347            if fallow_types::suppress::is_suppressed(
348                &module.suppressions,
349                function.line,
350                fallow_types::suppress::IssueKind::Complexity,
351            ) {
352                continue;
353            }
354
355            let exceeds_cyclomatic = function.cyclomatic > config.health.max_cyclomatic;
356            let exceeds_cognitive = function.cognitive > config.health.max_cognitive;
357            let exceeded = match (exceeds_cyclomatic, exceeds_cognitive) {
358                (true, true) => EditorInlineComplexityExceeded::CyclomaticAndCognitive,
359                (true, false) => EditorInlineComplexityExceeded::Cyclomatic,
360                (false, true) => EditorInlineComplexityExceeded::Cognitive,
361                (false, false) => continue,
362            };
363
364            findings.push(EditorInlineComplexityFinding {
365                path: (*path).clone(),
366                name: function.name.clone(),
367                line: function.line,
368                col: function.col,
369                cyclomatic: function.cyclomatic,
370                cognitive: function.cognitive,
371                exceeded,
372            });
373        }
374    }
375
376    findings
377}
378
379/// Filter inline complexity findings to the changed-file set.
380#[allow(
381    clippy::implicit_hasher,
382    reason = "editor analysis changed-file sets use the workspace FxHashSet convention"
383)]
384pub fn filter_inline_complexity_by_changed_files(
385    findings: &mut Vec<EditorInlineComplexityFinding>,
386    changed_files: &FxHashSet<PathBuf>,
387) {
388    findings.retain(|finding| changed_files.contains(&finding.path));
389}
390
391fn build_health_ignore_set(patterns: &[String]) -> Option<globset::GlobSet> {
392    if patterns.is_empty() {
393        return None;
394    }
395
396    let mut builder = globset::GlobSetBuilder::new();
397    for pattern in patterns {
398        let Ok(glob) = globset::Glob::new(pattern) else {
399            continue;
400        };
401        builder.add(glob);
402    }
403    builder.build().ok()
404}
405
406/// Reusable editor analysis session owned by the API boundary.
407#[derive(Debug)]
408pub struct EditorAnalysisSession {
409    inner: fallow_engine::session::AnalysisSession,
410}
411
412impl EditorAnalysisSession {
413    /// Load config and discover files for an editor project root.
414    ///
415    /// # Errors
416    ///
417    /// Returns an engine error when project config loading fails.
418    pub fn load(root: &Path, config_path: Option<&Path>) -> fallow_engine::EngineResult<Self> {
419        fallow_engine::session::AnalysisSession::load(root, config_path).map(Self::from_engine)
420    }
421
422    /// Load config, apply one editor-specific adjustment, then discover files.
423    ///
424    /// # Errors
425    ///
426    /// Returns an engine error when project config loading fails.
427    pub fn load_with_config(
428        root: &Path,
429        config_path: Option<&Path>,
430        configure: impl FnOnce(&mut fallow_config::ResolvedConfig),
431    ) -> fallow_engine::EngineResult<Self> {
432        fallow_engine::session::AnalysisSession::load_with_config(root, config_path, configure)
433            .map(Self::from_engine)
434    }
435
436    /// Load config with an explicit inheritance trust policy, apply one
437    /// editor-specific adjustment, then discover files.
438    ///
439    /// # Errors
440    ///
441    /// Returns an engine error when project config loading fails.
442    pub fn load_with_config_options(
443        root: &Path,
444        config_path: Option<&Path>,
445        load_options: fallow_config::ConfigLoadOptions,
446        configure: impl FnOnce(&mut fallow_config::ResolvedConfig),
447    ) -> fallow_engine::EngineResult<Self> {
448        fallow_engine::session::AnalysisSession::load_with_config_options(
449            root,
450            config_path,
451            load_options,
452            configure,
453        )
454        .map(Self::from_engine)
455    }
456
457    /// Build a session from built-in defaults, ignoring project config files.
458    #[must_use]
459    pub fn load_default(root: &Path) -> Self {
460        Self::from_engine(fallow_engine::session::AnalysisSession::load_default(root))
461    }
462
463    /// Resolved project config.
464    #[must_use]
465    pub fn config(&self) -> &fallow_config::ResolvedConfig {
466        self.inner.config()
467    }
468
469    /// Config file path when one was loaded.
470    #[must_use]
471    pub fn config_path(&self) -> Option<&Path> {
472        self.inner.config_path()
473    }
474
475    /// Refine this editor session's dead-code findings with exact TypeScript
476    /// symbol evidence.
477    ///
478    /// # Errors
479    ///
480    /// Returns a programmatic error when the semantic companion cannot provide
481    /// the requested analysis contract.
482    pub fn refine_type_aware_dead_code(
483        &self,
484        options: &crate::TypeAwareOptions,
485        filters: &crate::DeadCodeFilters,
486        output: &mut EditorDeadCodeAnalysisOutput,
487    ) -> Result<Option<fallow_types::envelope::TypeAwareMeta>, crate::ProgrammaticError> {
488        let meta = crate::type_aware::refine_programmatic_dead_code(
489            options,
490            filters,
491            &self.inner,
492            &mut output.results,
493        )?;
494        // Reconciliation can add findings, so rule severities are resolved
495        // again over the refined set. The pass only removes findings, so
496        // repeating it is idempotent.
497        fallow_engine::dead_code::apply_rule_severities(&mut output.results, self.inner.config());
498        Ok(meta)
499    }
500
501    /// Refine editor findings through a root-bound persistent semantic session.
502    pub fn refine_type_aware_dead_code_in_session(
503        &self,
504        semantic_session: &mut crate::TypeAwareSession,
505        changes: Option<&crate::TypeAwareFileChanges>,
506        options: &crate::TypeAwareOptions,
507        filters: &crate::DeadCodeFilters,
508        output: &mut EditorDeadCodeAnalysisOutput,
509    ) -> Result<Option<fallow_types::envelope::TypeAwareMeta>, crate::ProgrammaticError> {
510        let meta = crate::type_aware::refine_programmatic_dead_code_in_session(
511            semantic_session,
512            changes,
513            options,
514            filters,
515            &self.inner,
516            &mut output.results,
517        )?;
518        fallow_engine::dead_code::apply_rule_severities(&mut output.results, self.inner.config());
519        Ok(meta)
520    }
521
522    /// Run dead-code and duplication analysis for this editor session.
523    ///
524    /// # Errors
525    ///
526    /// Returns an engine error when dead-code parsing or analysis fails.
527    pub fn analyze_project_with(
528        &self,
529        duplicates_config: &fallow_config::DuplicatesConfig,
530        retain_complexity_artifacts: bool,
531    ) -> fallow_engine::EngineResult<EditorProjectAnalysisOutput> {
532        self.inner
533            .analyze_project_with(duplicates_config, retain_complexity_artifacts)
534            .map(EditorProjectAnalysisOutput::from_engine)
535            .map(|output| self.with_resolved_rule_severities(output))
536    }
537
538    /// Run dead-code and duplication analysis, optionally focusing duplication
539    /// to files the editor already resolved as changed.
540    ///
541    /// Dead-code still runs with full graph context so downstream editor
542    /// filters can preserve existing diagnostic semantics.
543    ///
544    /// # Errors
545    ///
546    /// Returns an engine error when dead-code parsing or analysis fails.
547    pub fn analyze_project_with_changed_files(
548        &self,
549        duplicates_config: &fallow_config::DuplicatesConfig,
550        retain_complexity_artifacts: bool,
551        changed_files: Option<&FxHashSet<PathBuf>>,
552    ) -> fallow_engine::EngineResult<EditorProjectAnalysisOutput> {
553        self.inner
554            .analyze_project_with_artifacts(
555                duplicates_config,
556                fallow_engine::project_analysis::ProjectAnalysisArtifactOptions {
557                    retain_complexity_artifacts,
558                    changed_files: changed_files.cloned(),
559                    ..fallow_engine::project_analysis::ProjectAnalysisArtifactOptions::default()
560                },
561            )
562            .map(fallow_engine::project_analysis::ProjectAnalysisArtifacts::into_output)
563            .map(EditorProjectAnalysisOutput::from_engine)
564            .map(|output| self.with_resolved_rule_severities(output))
565    }
566
567    /// Resolve configured rule severities, including per-path
568    /// `overrides[].rules`, against a freshly analyzed project slice.
569    ///
570    /// Each project root is filtered with its own config before a multi-root
571    /// editor session merges the outputs, so an override only ever applies to
572    /// the project that declares it.
573    fn with_resolved_rule_severities(
574        &self,
575        mut output: EditorProjectAnalysisOutput,
576    ) -> EditorProjectAnalysisOutput {
577        fallow_engine::dead_code::apply_rule_severities(
578            &mut output.dead_code.results,
579            self.inner.config(),
580        );
581        output
582    }
583
584    const fn from_engine(inner: fallow_engine::session::AnalysisSession) -> Self {
585        Self { inner }
586    }
587}
588
589/// Dead-code and duplication project output owned by the editor API boundary.
590#[derive(Debug)]
591pub struct EditorProjectAnalysisOutput {
592    /// Dead-code findings plus optionally retained parse artifacts.
593    pub dead_code: EditorDeadCodeAnalysisOutput,
594    /// Duplication report for the analyzed project slice.
595    pub duplication: EditorDuplicationReport,
596}
597
598impl EditorProjectAnalysisOutput {
599    fn from_engine(output: fallow_engine::project_analysis::ProjectAnalysisOutput) -> Self {
600        Self {
601            dead_code: EditorDeadCodeAnalysisOutput::from_engine(output.dead_code),
602            duplication: output.duplication,
603        }
604    }
605}
606
607/// Dead-code and duplication output shaped for editor integrations.
608#[derive(Debug, Default)]
609pub struct EditorAnalysisOutput {
610    /// Typed dead-code findings.
611    pub results: EditorAnalysisResults,
612    /// Duplication report.
613    pub duplication: EditorDuplicationReport,
614}
615
616impl EditorAnalysisOutput {
617    /// Pair dead-code results with a duplication report.
618    #[must_use]
619    pub const fn new(results: EditorAnalysisResults, duplication: EditorDuplicationReport) -> Self {
620        Self {
621            results,
622            duplication,
623        }
624    }
625
626    /// Convert a project analysis output, dropping retained parse artifacts.
627    #[must_use]
628    pub fn from_project_output(output: EditorProjectAnalysisOutput) -> Self {
629        Self::new(output.dead_code.results, output.duplication)
630    }
631
632    /// Merge another project analysis output into this accumulated output.
633    pub fn merge_project_output(&mut self, output: EditorProjectAnalysisOutput) {
634        self.merge_results(output.dead_code.results);
635        self.merge_duplication(output.duplication);
636    }
637
638    /// Merge another dead-code results set into this one.
639    pub fn merge_results(&mut self, source: EditorAnalysisResults) {
640        self.results.merge_into(source);
641    }
642
643    /// Merge another duplication report into this one, summing the aggregate
644    /// stats and recomputing the duplication percentage over the union.
645    pub fn merge_duplication(&mut self, source: EditorDuplicationReport) {
646        self.duplication.clone_groups.extend(source.clone_groups);
647        self.duplication
648            .clone_families
649            .extend(source.clone_families);
650        self.duplication
651            .mirrored_directories
652            .extend(source.mirrored_directories);
653        self.duplication.stats.clone_groups += source.stats.clone_groups;
654        self.duplication.stats.clone_families += source.stats.clone_families;
655        self.duplication.stats.clone_instances += source.stats.clone_instances;
656        self.duplication.stats.total_files += source.stats.total_files;
657        self.duplication.stats.files_with_clones += source.stats.files_with_clones;
658        self.duplication.stats.total_lines += source.stats.total_lines;
659        self.duplication.stats.duplicated_lines += source.stats.duplicated_lines;
660        self.duplication.stats.total_tokens += source.stats.total_tokens;
661        self.duplication.stats.duplicated_tokens += source.stats.duplicated_tokens;
662        self.duplication.stats.clone_groups_below_min_occurrences +=
663            source.stats.clone_groups_below_min_occurrences;
664        self.duplication.stats.clone_groups_ignored += source.stats.clone_groups_ignored;
665        self.duplication.stats.near_candidates_skipped += source.stats.near_candidates_skipped;
666        self.duplication.stats.duplication_percentage = if self.duplication.stats.total_lines > 0 {
667            (self.duplication.stats.duplicated_lines as f64
668                / self.duplication.stats.total_lines as f64)
669                * 100.0
670        } else {
671            0.0
672        };
673    }
674
675    /// Drop findings and clone groups that do not touch any changed file.
676    pub fn filter_by_changed_files(&mut self, changed_files: &FxHashSet<PathBuf>, root: &Path) {
677        fallow_engine::changed_files::filter_results_by_changed_files(
678            &mut self.results,
679            changed_files,
680        );
681        fallow_engine::changed_files::filter_duplication_by_changed_files(
682            &mut self.duplication,
683            changed_files,
684            root,
685        );
686    }
687
688    /// Resolve files changed since `git_ref` and filter to them, returning
689    /// how many files changed.
690    ///
691    /// # Errors
692    ///
693    /// Returns a changed-file error when git cannot resolve the ref or
694    /// repository state.
695    pub fn filter_by_changed_since(
696        &mut self,
697        root: &Path,
698        toplevel: &Path,
699        git_ref: &str,
700    ) -> Result<usize, ChangedFilesError> {
701        let changed = try_get_changed_files_with_toplevel(root, toplevel, git_ref)?;
702        let changed_count = changed.len();
703        self.filter_by_changed_files(&changed, root);
704        Ok(changed_count)
705    }
706}
707
708#[cfg(test)]
709mod tests {
710    use super::*;
711
712    use fallow_types::duplicates::{CloneFamily, CloneGroup, CloneInstance, DuplicationStats};
713
714    #[test]
715    fn merges_duplication_stats_and_recomputes_percentage() {
716        let mut output = EditorAnalysisOutput {
717            duplication: EditorDuplicationReport {
718                clone_groups: vec![CloneGroup {
719                    instances: vec![CloneInstance {
720                        file: PathBuf::from("src/a.ts"),
721                        start_line: 1,
722                        end_line: 4,
723                        start_col: 0,
724                        end_col: 10,
725                        fragment: "const a = 1;".to_string(),
726                    }],
727                    token_count: 8,
728                    line_count: 4,
729                    similarity: None,
730                }],
731                clone_families: Vec::new(),
732                mirrored_directories: Vec::new(),
733                stats: DuplicationStats {
734                    clone_groups: 1,
735                    clone_families: 0,
736                    clone_instances: 1,
737                    total_files: 1,
738                    files_with_clones: 1,
739                    total_lines: 20,
740                    duplicated_lines: 4,
741                    total_tokens: 80,
742                    duplicated_tokens: 8,
743                    duplication_percentage: 20.0,
744                    clone_groups_below_min_occurrences: 1,
745                    clone_groups_ignored: 1,
746                    near_candidates_skipped: 2,
747                },
748            },
749            ..Default::default()
750        };
751
752        output.merge_duplication(EditorDuplicationReport {
753            clone_groups: Vec::new(),
754            clone_families: Vec::new(),
755            mirrored_directories: Vec::new(),
756            stats: DuplicationStats {
757                clone_groups: 0,
758                clone_families: 0,
759                clone_instances: 0,
760                total_files: 1,
761                files_with_clones: 0,
762                total_lines: 30,
763                duplicated_lines: 6,
764                total_tokens: 120,
765                duplicated_tokens: 12,
766                duplication_percentage: 20.0,
767                clone_groups_below_min_occurrences: 2,
768                clone_groups_ignored: 3,
769                near_candidates_skipped: 4,
770            },
771        });
772
773        assert_eq!(output.duplication.stats.total_lines, 50);
774        assert_eq!(output.duplication.stats.duplicated_lines, 10);
775        assert_eq!(
776            output.duplication.stats.clone_groups_below_min_occurrences,
777            3
778        );
779        assert_eq!(output.duplication.stats.clone_groups_ignored, 4);
780        assert_eq!(output.duplication.stats.near_candidates_skipped, 6);
781        assert!((output.duplication.stats.duplication_percentage - 20.0).abs() < f64::EPSILON);
782    }
783
784    #[test]
785    fn merging_duplication_keeps_the_family_corpus_count_aligned() {
786        let family = |path: &str| CloneFamily {
787            files: vec![PathBuf::from(path)],
788            groups: Vec::new(),
789            total_duplicated_lines: 4,
790            total_duplicated_tokens: 8,
791            suggestions: Vec::new(),
792        };
793        let report = |path: &str, families: usize| EditorDuplicationReport {
794            clone_groups: Vec::new(),
795            clone_families: vec![family(path)],
796            mirrored_directories: Vec::new(),
797            stats: DuplicationStats {
798                clone_families: families,
799                ..DuplicationStats::default()
800            },
801        };
802
803        let mut output = EditorAnalysisOutput {
804            duplication: report("src/a.ts", 3),
805            ..Default::default()
806        };
807        output.merge_duplication(report("src/b.ts", 2));
808
809        assert_eq!(output.duplication.stats.clone_families, 5);
810        assert_eq!(output.duplication.clone_families_shown(), 2);
811        assert_eq!(output.duplication.clone_families_omitted(), 3);
812        assert_eq!(
813            output.duplication.clone_families_total(),
814            output.duplication.stats.clone_families
815        );
816    }
817
818    #[test]
819    fn editor_session_returns_api_owned_project_output() {
820        let temp = tempfile::tempdir().expect("temp project");
821        let root = temp.path();
822        std::fs::create_dir_all(root.join("src")).expect("src dir");
823        std::fs::write(
824            root.join("package.json"),
825            r#"{"name":"editor-api-session","main":"src/index.ts"}"#,
826        )
827        .expect("package.json");
828        std::fs::write(
829            root.join("src/index.ts"),
830            "export const used = 1;\nconsole.log(used);\n",
831        )
832        .expect("source");
833
834        let session = EditorAnalysisSession::load(root, None).expect("session loads");
835        let output = session
836            .analyze_project_with(&fallow_config::DuplicatesConfig::default(), true)
837            .expect("analysis runs");
838
839        assert!(output.dead_code.modules.is_some());
840        assert!(
841            output
842                .dead_code
843                .files
844                .as_ref()
845                .is_some_and(|files| !files.is_empty())
846        );
847    }
848
849    #[test]
850    fn editor_session_scopes_duplication_to_changed_files() {
851        let temp = tempfile::tempdir().expect("temp project");
852        let root = temp.path();
853        let src = root.join("src");
854        std::fs::create_dir_all(&src).expect("src dir");
855        std::fs::write(
856            root.join("package.json"),
857            r#"{"name":"editor-api-session","main":"src/a.ts"}"#,
858        )
859        .expect("package.json");
860        let repeated =
861            "export function repeated() {\n  return ['alpha', 'beta', 'gamma'].join(',');\n}\n";
862        std::fs::write(src.join("a.ts"), repeated).expect("source a");
863        std::fs::write(src.join("b.ts"), repeated).expect("source b");
864
865        let session = EditorAnalysisSession::load(root, None).expect("session loads");
866        let mut config = session.config().duplicates.clone();
867        config.min_tokens = 1;
868        config.min_lines = 1;
869        let full = session
870            .analyze_project_with(&config, false)
871            .expect("analysis runs");
872        assert!(!full.duplication.clone_groups.is_empty());
873
874        let mut changed_files = FxHashSet::default();
875        changed_files.insert(src.join("unrelated.ts"));
876        let scoped = session
877            .analyze_project_with_changed_files(&config, false, Some(&changed_files))
878            .expect("analysis runs");
879        assert!(scoped.duplication.clone_groups.is_empty());
880    }
881
882    #[test]
883    fn build_health_ignore_set_returns_none_for_empty_patterns() {
884        assert!(
885            build_health_ignore_set(&[]).is_none(),
886            "empty ignore pattern list should avoid building a matcher"
887        );
888    }
889
890    #[test]
891    fn build_health_ignore_set_matches_glob_patterns() {
892        let set =
893            build_health_ignore_set(&["**/*.test.ts".to_string(), "src/generated/**".to_string()])
894                .expect("valid patterns build a glob set");
895
896        assert!(set.is_match(Path::new("src/foo.test.ts")));
897        assert!(set.is_match(Path::new("src/generated/client.ts")));
898        assert!(!set.is_match(Path::new("src/app.ts")));
899    }
900
901    #[test]
902    fn build_health_ignore_set_skips_invalid_patterns() {
903        let result = build_health_ignore_set(&["[invalid-glob".to_string()]);
904
905        match result {
906            None => {}
907            Some(set) => assert!(
908                !set.is_match(Path::new("any/path.ts")),
909                "set built from only invalid patterns must not match anything"
910            ),
911        }
912    }
913
914    fn make_inline_finding(path: PathBuf) -> EditorInlineComplexityFinding {
915        EditorInlineComplexityFinding {
916            path,
917            name: "myFn".to_string(),
918            line: 1,
919            col: 0,
920            cyclomatic: 5,
921            cognitive: 4,
922            exceeded: EditorInlineComplexityExceeded::Cyclomatic,
923        }
924    }
925
926    #[test]
927    fn filter_inline_complexity_keeps_findings_in_changed_set() {
928        let changed: FxHashSet<PathBuf> = [PathBuf::from("/src/a.ts"), PathBuf::from("/src/b.ts")]
929            .into_iter()
930            .collect();
931        let mut findings = vec![
932            make_inline_finding(PathBuf::from("/src/a.ts")),
933            make_inline_finding(PathBuf::from("/src/c.ts")),
934        ];
935
936        filter_inline_complexity_by_changed_files(&mut findings, &changed);
937
938        assert_eq!(findings.len(), 1);
939        assert_eq!(
940            findings[0].path.to_string_lossy().replace('\\', "/"),
941            "/src/a.ts"
942        );
943    }
944
945    #[test]
946    fn filter_inline_complexity_removes_all_when_changed_set_empty() {
947        let changed: FxHashSet<PathBuf> = FxHashSet::default();
948        let mut findings = vec![make_inline_finding(PathBuf::from("/src/a.ts"))];
949
950        filter_inline_complexity_by_changed_files(&mut findings, &changed);
951
952        assert!(
953            findings.is_empty(),
954            "empty changed-files set must drop all inline complexity findings"
955        );
956    }
957
958    #[test]
959    fn filter_inline_complexity_keeps_all_when_all_in_changed_set() {
960        let path_a = PathBuf::from("/src/a.ts");
961        let path_b = PathBuf::from("/src/b.ts");
962        let changed: FxHashSet<PathBuf> = [path_a.clone(), path_b.clone()].into_iter().collect();
963        let mut findings = vec![make_inline_finding(path_a), make_inline_finding(path_b)];
964
965        filter_inline_complexity_by_changed_files(&mut findings, &changed);
966
967        assert_eq!(
968            findings.len(),
969            2,
970            "all findings in the changed set must be retained"
971        );
972    }
973
974    #[test]
975    fn editor_session_applies_per_path_rule_overrides() {
976        // The editor analysis path must resolve `overrides[].rules` the same
977        // way the CLI does, so inline diagnostics and `fallow dead-code` agree
978        // on which findings a project has turned off (issue #2621).
979        let temp = tempfile::tempdir().expect("temp project");
980        let root = temp.path();
981        std::fs::create_dir_all(root.join("src/ui")).expect("ui dir");
982        std::fs::create_dir_all(root.join("src/lib")).expect("lib dir");
983        std::fs::write(
984            root.join("package.json"),
985            r#"{"name":"editor-override-rules","private":true,"main":"src/index.ts"}"#,
986        )
987        .expect("package.json");
988        std::fs::write(
989            root.join(".fallowrc.json"),
990            r#"{
991  "rules": { "unused-exports": "warn", "private-type-leaks": "warn" },
992  "overrides": [
993    {
994      "files": ["src/ui/**"],
995      "rules": { "unused-exports": "off", "private-type-leaks": "off" }
996    }
997  ]
998}"#,
999        )
1000        .expect("config");
1001        std::fs::write(
1002            root.join("src/index.ts"),
1003            "import { kitUsed } from './ui/kit';\nimport { libUsed } from './lib/util';\n\nexport const app = `${kitUsed}${libUsed}`;\n",
1004        )
1005        .expect("index");
1006        std::fs::write(
1007            root.join("src/ui/kit.ts"),
1008            "type Props = { label: string };\n\nexport const kitUsed = 'kit';\n\nexport const Unused = (props: Props) => props.label;\n",
1009        )
1010        .expect("kit");
1011        std::fs::write(
1012            root.join("src/lib/util.ts"),
1013            "type Internal = { id: string };\n\nexport const libUsed = 'lib';\n\nexport const alsoUnused = (value: Internal) => value.id;\n",
1014        )
1015        .expect("util");
1016
1017        let session = EditorAnalysisSession::load(root, None).expect("session loads");
1018        let output = session
1019            .analyze_project_with_changed_files(
1020                &fallow_config::DuplicatesConfig::default(),
1021                false,
1022                None,
1023            )
1024            .expect("analysis runs");
1025        let results = &output.dead_code.results;
1026
1027        let unused_export_paths = || {
1028            results
1029                .unused_exports
1030                .iter()
1031                .map(|finding| finding.export.path.clone())
1032                .collect::<Vec<_>>()
1033        };
1034        let leak_paths = || {
1035            results
1036                .private_type_leaks
1037                .iter()
1038                .map(|finding| finding.leak.path.clone())
1039                .collect::<Vec<_>>()
1040        };
1041
1042        assert!(
1043            !unused_export_paths()
1044                .iter()
1045                .any(|path| path.ends_with("kit.ts")),
1046            "the override turns unused-exports off for src/ui/**: {:?}",
1047            unused_export_paths()
1048        );
1049        assert!(
1050            !leak_paths().iter().any(|path| path.ends_with("kit.ts")),
1051            "the override turns private-type-leaks off for src/ui/**: {:?}",
1052            leak_paths()
1053        );
1054        assert!(
1055            unused_export_paths()
1056                .iter()
1057                .any(|path| path.ends_with("util.ts")),
1058            "paths outside the override keep their unused export: {:?}",
1059            unused_export_paths()
1060        );
1061        assert!(
1062            leak_paths().iter().any(|path| path.ends_with("util.ts")),
1063            "paths outside the override keep their private type leak: {:?}",
1064            leak_paths()
1065        );
1066    }
1067}