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
9use crate::{ChangeScope, ChangeScopeRequest, PackageBaselineError};
10
11/// Editor-boundary alias for the clone-family payload.
12pub type EditorCloneFamily = fallow_types::duplicates::CloneFamily;
13/// Editor-boundary alias for the clone-group payload.
14pub type EditorCloneGroup = fallow_types::duplicates::CloneGroup;
15/// Editor-boundary alias for one clone occurrence within a group.
16pub type EditorCloneInstance = fallow_types::duplicates::CloneInstance;
17/// Editor-boundary alias for the full duplication report.
18pub type EditorDuplicationReport = fallow_types::duplicates::DuplicationReport;
19/// Editor-boundary alias for aggregate duplication statistics.
20pub type EditorDuplicationStats = fallow_types::duplicates::DuplicationStats;
21/// Editor-boundary alias for a mirrored-directory finding.
22pub type EditorMirroredDirectory = fallow_types::duplicates::MirroredDirectory;
23/// Editor-boundary alias for the refactoring-suggestion kind.
24pub type EditorRefactoringKind = fallow_types::duplicates::RefactoringKind;
25/// Editor-boundary alias for a refactoring suggestion.
26pub type EditorRefactoringSuggestion = fallow_types::duplicates::RefactoringSuggestion;
27
28/// Report-scoped clone fingerprint assignment for editor-facing duplication output.
29#[derive(Debug, Clone)]
30pub struct EditorCloneFingerprintSet {
31    inner: fallow_engine::duplicates::CloneFingerprintSet,
32}
33
34impl EditorCloneFingerprintSet {
35    /// Assign collision-free fingerprints for clone groups in one report.
36    #[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    /// Return the assigned fingerprint for a clone group.
44    #[must_use]
45    pub fn fingerprint_for_group(&self, group: &EditorCloneGroup) -> String {
46        self.inner.fingerprint_for_group(group)
47    }
48
49    /// Return the assigned fingerprint for clone-group parts.
50    #[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    /// Find the group addressed by an assigned fingerprint.
62    #[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
72/// Duplication contracts re-exported under their unprefixed names so editor
73/// adapters can import them as a module namespace.
74pub 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/// Classification of a changed-file git failure for editor integrations.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub enum ChangedFilesError {
87    /// Git ref failed validation before invoking `git`.
88    InvalidRef(String),
89    /// `git` binary not found or not executable.
90    GitMissing(String),
91    /// Command ran but the directory is not a git repository.
92    NotARepository,
93    /// Command ran but the ref is invalid or another git error occurred.
94    GitFailed(String),
95}
96
97impl ChangedFilesError {
98    /// Human-readable clause suitable for embedding in an error message.
99    #[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
139/// Resolve the canonical git toplevel for `cwd`.
140///
141/// # Errors
142///
143/// Returns an API-owned changed-file error when git cannot inspect the
144/// repository.
145pub fn resolve_git_toplevel(cwd: &Path) -> Result<PathBuf, ChangedFilesError> {
146    fallow_engine::changed_files::resolve_git_toplevel(cwd).map_err(ChangedFilesError::from)
147}
148
149/// Get changed files and the git toplevel used to resolve them.
150///
151/// # Errors
152///
153/// Returns an API-owned changed-file error when git cannot resolve the ref or
154/// repository state.
155pub 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
164/// Per-module extraction facts re-exported for editor adapters that inspect
165/// retained parse artifacts.
166pub 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
190/// Typed analysis-result and finding contracts re-exported for editor
191/// adapters.
192pub mod editor_results {
193    pub use fallow_types::output_dead_code::{
194        BoundaryCallViolationFinding, BoundaryCoverageViolationFinding, BoundaryViolationFinding,
195        CircularDependencyFinding, DeprecatedExportInUseFinding, DevDependencyInProductionFinding,
196        DuplicateExportFinding, DuplicatePropShapeFinding, DynamicSegmentNameConflictFinding,
197        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        ActiveSuppression, AnalysisResults, BoundaryCallViolation, BoundaryCoverageViolation,
213        BoundaryViolation, CircularDependency, CircularDependencyEdge, DependencyLocation,
214        DependencyOverrideMisconfigReason, DependencyOverrideSource, DeprecatedConsumerKind,
215        DeprecatedExportConsumer, DeprecatedExportInUse, DevDependencyInProduction,
216        DuplicateExport, DuplicateLocation, DuplicatePropShape, DuplicatePropShapeMember,
217        DynamicSegmentNameConflict, EmptyCatalogGroup, EntryPointSummary, ExportUsage, FeatureFlag,
218        FlagConfidence, FlagKind, ImportSite, InvalidClientExport, MisconfiguredDependencyOverride,
219        MisplacedDirective, MixedClientServerBarrel, PackageCycle, PackageCycleEdge,
220        PolicyRuleKind, PolicyViolation, PolicyViolationSeverity, PrivateTypeLeak, PropDrillHop,
221        PropDrillingChain, ReExportCycle, ReExportCycleKind, ReactComponentIntel, ReactHookSummary,
222        ReactPropDrill, ReactPropIntel, ReferenceLocation, RenderFanInComponent, RenderFanInMetric,
223        RouteCollision, SecurityAttackSurfaceEntry, SecurityCandidate, SecurityCandidateBoundary,
224        SecurityCandidateSink, SecurityDeadCodeContext, SecurityDeadCodeKind,
225        SecurityDefensiveBoundary, SecurityDefensiveControl, SecurityFinding, SecurityFindingKind,
226        SecurityNetworkContext, SecurityReachability, SecurityRuntimeContext, SecurityRuntimeState,
227        SecuritySeverity, SecurityTaintFlow, SecurityUnresolvedCalleeDiagnostic,
228        SecurityZoneCrossing, StaleSuppression, SuppressionOrigin, TaintConfidence, TaintEndpoint,
229        TaintPath, TestOnlyDependency, ThinWrapper, TraceHop, TraceHopRole, TypeOnlyDependency,
230        UnlistedDependency, UnprovidedInject, UnrenderedComponent, UnresolvedCatalogReference,
231        UnresolvedImport, UnusedCatalogEntry, UnusedComponentEmit, UnusedComponentInput,
232        UnusedComponentOutput, UnusedComponentProp, UnusedDependency, UnusedDependencyOverride,
233        UnusedExport, UnusedFile, UnusedLoadDataKey, UnusedMember, UnusedServerAction,
234        UnusedSvelteEvent,
235    };
236}
237
238/// Security catalogue lookups exposed at the editor boundary.
239pub mod editor_security {
240    /// Return the human-readable security catalogue title for a finding kind.
241    #[must_use]
242    pub fn security_catalogue_title(kind: &str) -> Option<&'static str> {
243        fallow_engine::dead_code::security_catalogue_title(kind)
244    }
245}
246
247/// Inline-suppression contracts re-exported for editor adapters.
248pub mod editor_suppress {
249    pub use fallow_types::suppress::{IssueKind, is_suppressed};
250}
251
252/// Editor-boundary alias for the typed dead-code analysis results.
253pub type EditorAnalysisResults = fallow_types::results::AnalysisResults;
254
255/// Dead-code output retained for editor integrations.
256///
257/// The engine produces the data, but the editor API owns this public contract
258/// so LSP and future editor adapters do not depend on engine result structs.
259#[derive(Debug)]
260pub struct EditorDeadCodeAnalysisOutput {
261    /// Typed dead-code findings from the analysis.
262    pub results: EditorAnalysisResults,
263    /// Retained per-module parse artifacts; `None` unless the run was asked
264    /// to keep them for follow-up editor features.
265    pub modules: Option<Vec<ModuleInfo>>,
266    /// Retained discovered-file records matching `modules`.
267    pub files: Option<Vec<DiscoveredFile>>,
268}
269
270impl EditorDeadCodeAnalysisOutput {
271    fn from_engine(output: fallow_engine::dead_code::DeadCodeAnalysisOutput) -> Self {
272        Self {
273            results: output.results,
274            modules: output.modules,
275            files: output.files,
276        }
277    }
278}
279
280/// Editor-facing inline complexity signal for code lens and similar surfaces.
281///
282/// The finding is derived from retained typed engine parse artifacts, but the
283/// editor API owns the stable shape so LSP and future editor adapters do not
284/// need to inspect raw modules directly.
285#[derive(Debug, Clone, PartialEq, Eq)]
286pub struct EditorInlineComplexityFinding {
287    /// Absolute path of the file declaring the function.
288    pub path: PathBuf,
289    /// Function name as extracted from the source.
290    pub name: String,
291    /// One-based line of the function declaration.
292    pub line: u32,
293    /// Zero-based column of the function declaration.
294    pub col: u32,
295    /// Measured cyclomatic complexity.
296    pub cyclomatic: u16,
297    /// Measured cognitive complexity.
298    pub cognitive: u16,
299    /// Which configured threshold(s) the function exceeded.
300    pub exceeded: EditorInlineComplexityExceeded,
301}
302
303/// Which health complexity threshold(s) a function exceeded.
304#[derive(Debug, Clone, Copy, PartialEq, Eq)]
305pub enum EditorInlineComplexityExceeded {
306    /// Only the cyclomatic threshold was exceeded.
307    Cyclomatic,
308    /// Only the cognitive threshold was exceeded.
309    Cognitive,
310    /// Both thresholds were exceeded.
311    CyclomaticAndCognitive,
312}
313
314/// Collect inline complexity findings from retained editor analysis artifacts.
315///
316/// The rule is `fallow_engine::health::inline_complexity`, the one that the
317/// health findings use, so the code lens and `fallow health` flag the same
318/// functions, also under `health.thresholdOverrides`.
319#[must_use]
320pub fn collect_inline_complexity(
321    config: &fallow_config::ResolvedConfig,
322    output: &EditorDeadCodeAnalysisOutput,
323) -> Vec<EditorInlineComplexityFinding> {
324    let (Some(modules), Some(files)) = (output.modules.as_ref(), output.files.as_ref()) else {
325        return Vec::new();
326    };
327    fallow_engine::health::inline_complexity(config, modules, files)
328        .into_iter()
329        .map(|finding| EditorInlineComplexityFinding {
330            exceeded: match (finding.exceeds_cyclomatic, finding.exceeds_cognitive) {
331                (true, true) => EditorInlineComplexityExceeded::CyclomaticAndCognitive,
332                (true, false) => EditorInlineComplexityExceeded::Cyclomatic,
333                (false, _) => EditorInlineComplexityExceeded::Cognitive,
334            },
335            path: finding.path,
336            name: finding.name,
337            line: finding.line,
338            col: finding.col,
339            cyclomatic: finding.cyclomatic,
340            cognitive: finding.cognitive,
341        })
342        .collect()
343}
344
345/// Filter inline complexity findings to the changed-file set.
346#[allow(
347    clippy::implicit_hasher,
348    reason = "editor analysis changed-file sets use the workspace FxHashSet convention"
349)]
350pub fn filter_inline_complexity_by_changed_files(
351    findings: &mut Vec<EditorInlineComplexityFinding>,
352    changed_files: &FxHashSet<PathBuf>,
353) {
354    findings.retain(|finding| changed_files.contains(&finding.path));
355}
356
357/// Retain the inline complexity findings in a resolved change scope.
358pub fn filter_inline_complexity_by_change_scope(
359    findings: &mut Vec<EditorInlineComplexityFinding>,
360    scope: &ChangeScope,
361) {
362    findings.retain(|finding| scope.contains(&finding.path));
363}
364
365/// The parse work of an editor session. See
366/// [`fallow_engine::session::SessionParseCounts`].
367pub type EditorSessionParseCounts = fallow_engine::session::SessionParseCounts;
368
369/// Reusable editor analysis session owned by the API boundary.
370#[derive(Debug)]
371pub struct EditorAnalysisSession {
372    inner: fallow_engine::session::AnalysisSession,
373}
374
375impl EditorAnalysisSession {
376    /// Load config and discover files for an editor project root.
377    ///
378    /// # Errors
379    ///
380    /// Returns an engine error when project config loading fails.
381    pub fn load(root: &Path, config_path: Option<&Path>) -> fallow_engine::EngineResult<Self> {
382        fallow_engine::session::AnalysisSession::load(root, config_path).map(Self::from_engine)
383    }
384
385    /// Load config, apply one editor-specific adjustment, then discover files.
386    ///
387    /// # Errors
388    ///
389    /// Returns an engine error when project config loading fails.
390    pub fn load_with_config(
391        root: &Path,
392        config_path: Option<&Path>,
393        configure: impl FnOnce(&mut fallow_config::ResolvedConfig),
394    ) -> fallow_engine::EngineResult<Self> {
395        fallow_engine::session::AnalysisSession::load_with_config(root, config_path, configure)
396            .map(Self::from_engine)
397    }
398
399    /// Load config with an explicit inheritance trust policy, apply one
400    /// editor-specific adjustment, then discover files.
401    ///
402    /// # Errors
403    ///
404    /// Returns an engine error when project config loading fails.
405    pub fn load_with_config_options(
406        root: &Path,
407        config_path: Option<&Path>,
408        load_options: fallow_config::ConfigLoadOptions,
409        configure: impl FnOnce(&mut fallow_config::ResolvedConfig),
410    ) -> fallow_engine::EngineResult<Self> {
411        fallow_engine::session::AnalysisSession::load_with_config_options(
412            root,
413            config_path,
414            load_options,
415            configure,
416        )
417        .map(Self::from_engine)
418    }
419
420    /// Build a session from built-in defaults, ignoring project config files.
421    #[must_use]
422    pub fn load_default(root: &Path) -> Self {
423        Self::from_engine(fallow_engine::session::AnalysisSession::load_default(root))
424    }
425
426    /// Build a session from built-in defaults, apply one editor-specific
427    /// adjustment, then discover files.
428    #[must_use]
429    pub fn load_default_with_config(
430        root: &Path,
431        configure: impl FnOnce(&mut fallow_config::ResolvedConfig),
432    ) -> Self {
433        Self::from_engine(
434            fallow_engine::session::AnalysisSession::load_default_with_config(root, configure),
435        )
436    }
437
438    /// Attach a caller-owned cancellation token to this session.
439    ///
440    /// The analyses of the session check the token at each pipeline stage
441    /// boundary and in the per-file parse loop. Once the token is set, they
442    /// return an engine error whose `is_cancelled()` is true, never a partial
443    /// result. See
444    /// [`fallow_engine::session::AnalysisSession::with_cancellation`] for the
445    /// limits of the cooperative stop.
446    #[must_use]
447    pub fn with_cancellation(
448        self,
449        cancellation: std::sync::Arc<std::sync::atomic::AtomicBool>,
450    ) -> Self {
451        Self::from_engine(self.inner.with_cancellation(cancellation))
452    }
453
454    /// Replace the cancellation token of a session that serves several runs.
455    pub fn set_cancellation(
456        &mut self,
457        cancellation: std::sync::Arc<std::sync::atomic::AtomicBool>,
458    ) {
459        self.inner.set_cancellation(cancellation);
460    }
461
462    /// Walk the project again before a run of a long-lived session. The
463    /// parsed modules stay when the file set did not change. Returns whether
464    /// the file set changed. See
465    /// [`fallow_engine::session::AnalysisSession::refresh_discovery`].
466    pub fn refresh_discovery(&mut self) -> bool {
467        self.inner.refresh_discovery()
468    }
469
470    /// The plugin files, rule packs and `autoDiscover` directories that
471    /// config resolution read for this session. A long-lived caller compares
472    /// two snapshots of them to see that the config is out of date.
473    #[must_use]
474    pub fn config_inputs(&self) -> &fallow_config::ConfigInputs {
475        self.inner.config_inputs()
476    }
477
478    /// The content of [`Self::config_inputs`] just before config resolution
479    /// read them.
480    #[must_use]
481    pub fn config_inputs_before_resolve(&self) -> &fallow_config::ConfigInputsSnapshot {
482        self.inner.config_inputs_before_resolve()
483    }
484
485    /// The estimated heap memory of the parsed modules that the session
486    /// keeps between runs. Zero before the first parse.
487    #[must_use]
488    pub fn retained_bytes_estimate(&self) -> u64 {
489        self.inner.retained_bytes_estimate()
490    }
491
492    /// Write the modules of incremental parses to the persisted parse cache.
493    /// Call it before the session is dropped.
494    pub fn flush_parse_cache(&self) {
495        self.inner.flush_parse_cache();
496    }
497
498    /// Parse the project files into the session without analysis, so the
499    /// first run starts from warm modules.
500    ///
501    /// # Errors
502    ///
503    /// Returns a cancelled engine error when the token of the session is set.
504    pub fn prewarm(&self, retain_complexity_artifacts: bool) -> fallow_engine::EngineResult<()> {
505        self.inner
506            .prewarm_parsed_modules(retain_complexity_artifacts)
507    }
508
509    /// The parse work of this session since it was created.
510    #[must_use]
511    pub fn parse_counts(&self) -> EditorSessionParseCounts {
512        self.inner.parse_counts()
513    }
514
515    /// Resolved project config.
516    #[must_use]
517    pub fn config(&self) -> &fallow_config::ResolvedConfig {
518        self.inner.config()
519    }
520
521    /// Resolve the change scope of this project with the engine rule that
522    /// the CLI and the programmatic API use.
523    ///
524    /// # Errors
525    ///
526    /// Returns a typed error when the package map names an invalid or unknown
527    /// workspace root, or a Git ref that does not resolve.
528    pub fn change_scope(
529        &self,
530        request: ChangeScopeRequest<'_>,
531    ) -> Result<ChangeScope, PackageBaselineError> {
532        ChangeScope::resolve(request, self.inner.config(), self.inner.workspaces())
533    }
534
535    /// `workspace_diagnostics[]` entries for the config patterns
536    /// (`ignoreFindings`, `ignoreDependencies`) that matched nothing in the
537    /// latest analysis of this session. The CLI and the programmatic API
538    /// build the same entries with the same engine function.
539    #[must_use]
540    pub fn unmatched_config_patterns(&self) -> Vec<fallow_config::WorkspaceDiagnostic> {
541        fallow_engine::dead_code::config_pattern_diagnostics(self.inner.config(), true)
542    }
543
544    /// Config file path when one was loaded.
545    #[must_use]
546    pub fn config_path(&self) -> Option<&Path> {
547        self.inner.config_path()
548    }
549
550    /// Refine this editor session's dead-code findings with exact TypeScript
551    /// symbol evidence.
552    ///
553    /// # Errors
554    ///
555    /// Returns a programmatic error when the semantic companion cannot provide
556    /// the requested analysis contract.
557    pub fn refine_type_aware_dead_code(
558        &self,
559        options: &crate::TypeAwareOptions,
560        filters: &crate::DeadCodeFilters,
561        output: &mut EditorDeadCodeAnalysisOutput,
562    ) -> Result<Option<fallow_types::envelope::TypeAwareMeta>, crate::ProgrammaticError> {
563        let meta = crate::type_aware::refine_programmatic_dead_code(
564            options,
565            filters,
566            &self.inner,
567            &mut output.results,
568        )?;
569        // Reconciliation can add findings, so rule severities are resolved
570        // again over the refined set. The pass removes findings and writes
571        // each gate severity again, so repeating it is idempotent.
572        fallow_engine::dead_code::apply_rule_severities(&mut output.results, self.inner.config());
573        Ok(meta)
574    }
575
576    /// Refine editor findings through a root-bound persistent semantic session.
577    pub fn refine_type_aware_dead_code_in_session(
578        &self,
579        semantic_session: &mut crate::TypeAwareSession,
580        changes: Option<&crate::TypeAwareFileChanges>,
581        options: &crate::TypeAwareOptions,
582        filters: &crate::DeadCodeFilters,
583        output: &mut EditorDeadCodeAnalysisOutput,
584    ) -> Result<Option<fallow_types::envelope::TypeAwareMeta>, crate::ProgrammaticError> {
585        let meta = crate::type_aware::refine_programmatic_dead_code_in_session(
586            semantic_session,
587            changes,
588            options,
589            filters,
590            &self.inner,
591            &mut output.results,
592        )?;
593        fallow_engine::dead_code::apply_rule_severities(&mut output.results, self.inner.config());
594        Ok(meta)
595    }
596
597    /// Run dead-code and duplication analysis for this editor session.
598    ///
599    /// # Errors
600    ///
601    /// Returns an engine error when dead-code parsing or analysis fails.
602    pub fn analyze_project_with(
603        &self,
604        duplicates_config: &fallow_config::DuplicatesConfig,
605        retain_complexity_artifacts: bool,
606    ) -> fallow_engine::EngineResult<EditorProjectAnalysisOutput> {
607        self.inner
608            .analyze_project_with(duplicates_config, retain_complexity_artifacts)
609            .map(EditorProjectAnalysisOutput::from_engine)
610            .map(|output| self.with_resolved_rule_severities(output))
611    }
612
613    /// Run dead-code and duplication analysis, optionally focusing duplication
614    /// to files the editor already resolved as changed.
615    ///
616    /// Dead-code still runs with full graph context, and the dead-code
617    /// findings keep full scope until the type-aware pass has run. That pass
618    /// reads `unused_files` as its set of unreachable files. After the pass,
619    /// call [`Self::apply_change_scope`] to narrow the dead-code findings and
620    /// clone groups of this project.
621    ///
622    /// # Errors
623    ///
624    /// Returns an engine error when dead-code parsing or analysis fails.
625    pub fn analyze_project_with_changed_files(
626        &self,
627        duplicates_config: &fallow_config::DuplicatesConfig,
628        retain_complexity_artifacts: bool,
629        changed_files: Option<&FxHashSet<PathBuf>>,
630    ) -> fallow_engine::EngineResult<EditorProjectAnalysisOutput> {
631        self.inner
632            .analyze_project_with_artifacts(
633                duplicates_config,
634                fallow_engine::project_analysis::ProjectAnalysisArtifactOptions {
635                    retain_complexity_artifacts,
636                    changed_files: changed_files.cloned(),
637                    ..fallow_engine::project_analysis::ProjectAnalysisArtifactOptions::default()
638                },
639            )
640            .map(fallow_engine::project_analysis::ProjectAnalysisArtifacts::into_output)
641            .map(EditorProjectAnalysisOutput::from_engine)
642            .map(|output| self.with_resolved_rule_severities(output))
643    }
644
645    /// Narrow the dead-code findings and clone groups of this project to a
646    /// resolved change scope, with the engine functions that the CLI, MCP and
647    /// Node API use.
648    ///
649    /// Call it after the type-aware pass. That pass reads `unused_files` as
650    /// its set of unreachable files, so a scope before it drops evidence from
651    /// unused files outside the changed set. A multi-root editor session
652    /// merges several projects, and each project has its own
653    /// `ignoreFindings`. So the scope runs per project, where the config is
654    /// known, and not after the merge.
655    pub fn apply_change_scope(
656        &self,
657        output: &mut EditorProjectAnalysisOutput,
658        scope: &ChangeScope,
659    ) {
660        fallow_engine::dead_code::apply_scope(
661            &mut output.dead_code.results,
662            &fallow_engine::dead_code::DeadCodeScope {
663                workspace_roots: None,
664                changes: Some(scope),
665                diff: None,
666                files: None,
667            },
668            self.inner.config(),
669        );
670        fallow_engine::duplicates::apply_scope(
671            &mut output.duplication,
672            &fallow_engine::duplicates::DuplicationScope {
673                changes: Some(scope),
674                diff: None,
675                workspace_roots: None,
676            },
677            &self.inner.config().root,
678        );
679    }
680
681    /// Resolve configured rule severities, including per-path
682    /// `overrides[].rules`, against a freshly analyzed project slice.
683    ///
684    /// Each project root is filtered with its own config before a multi-root
685    /// editor session merges the outputs, so an override only ever applies to
686    /// the project that declares it.
687    fn with_resolved_rule_severities(
688        &self,
689        mut output: EditorProjectAnalysisOutput,
690    ) -> EditorProjectAnalysisOutput {
691        fallow_engine::dead_code::apply_rule_severities(
692            &mut output.dead_code.results,
693            self.inner.config(),
694        );
695        output
696    }
697
698    const fn from_engine(inner: fallow_engine::session::AnalysisSession) -> Self {
699        Self { inner }
700    }
701}
702
703/// Dead-code and duplication project output owned by the editor API boundary.
704#[derive(Debug)]
705pub struct EditorProjectAnalysisOutput {
706    /// Dead-code findings plus optionally retained parse artifacts.
707    pub dead_code: EditorDeadCodeAnalysisOutput,
708    /// Duplication report for the analyzed project slice.
709    pub duplication: EditorDuplicationReport,
710}
711
712impl EditorProjectAnalysisOutput {
713    fn from_engine(output: fallow_engine::project_analysis::ProjectAnalysisOutput) -> Self {
714        Self {
715            dead_code: EditorDeadCodeAnalysisOutput::from_engine(output.dead_code),
716            duplication: output.duplication,
717        }
718    }
719}
720
721/// Dead-code and duplication output shaped for editor integrations.
722#[derive(Debug, Default)]
723pub struct EditorAnalysisOutput {
724    /// Typed dead-code findings.
725    pub results: EditorAnalysisResults,
726    /// Duplication report.
727    pub duplication: EditorDuplicationReport,
728}
729
730impl EditorAnalysisOutput {
731    /// Pair dead-code results with a duplication report.
732    #[must_use]
733    pub const fn new(results: EditorAnalysisResults, duplication: EditorDuplicationReport) -> Self {
734        Self {
735            results,
736            duplication,
737        }
738    }
739
740    /// Merge another project analysis output into this accumulated output.
741    pub fn merge_project_output(&mut self, output: EditorProjectAnalysisOutput) {
742        self.merge_results(output.dead_code.results);
743        self.merge_duplication(output.duplication);
744    }
745
746    /// Merge another dead-code results set into this one.
747    pub fn merge_results(&mut self, source: EditorAnalysisResults) {
748        self.results.merge_into(source);
749    }
750
751    /// Merge another duplication report into this one, summing the aggregate
752    /// stats and recomputing the duplication percentage over the union.
753    pub fn merge_duplication(&mut self, source: EditorDuplicationReport) {
754        self.duplication.clone_groups.extend(source.clone_groups);
755        self.duplication
756            .clone_families
757            .extend(source.clone_families);
758        self.duplication
759            .mirrored_directories
760            .extend(source.mirrored_directories);
761        self.duplication.stats.clone_groups += source.stats.clone_groups;
762        self.duplication.stats.clone_families += source.stats.clone_families;
763        self.duplication.stats.clone_instances += source.stats.clone_instances;
764        self.duplication.stats.total_files += source.stats.total_files;
765        self.duplication.stats.files_with_clones += source.stats.files_with_clones;
766        self.duplication.stats.total_lines += source.stats.total_lines;
767        self.duplication.stats.duplicated_lines += source.stats.duplicated_lines;
768        self.duplication.stats.total_tokens += source.stats.total_tokens;
769        self.duplication.stats.duplicated_tokens += source.stats.duplicated_tokens;
770        self.duplication.stats.clone_groups_below_min_occurrences +=
771            source.stats.clone_groups_below_min_occurrences;
772        self.duplication.stats.clone_groups_ignored += source.stats.clone_groups_ignored;
773        self.duplication.stats.near_candidates_skipped += source.stats.near_candidates_skipped;
774        self.duplication.stats.duplication_percentage = if self.duplication.stats.total_lines > 0 {
775            (self.duplication.stats.duplicated_lines as f64
776                / self.duplication.stats.total_lines as f64)
777                * 100.0
778        } else {
779            0.0
780        };
781    }
782
783    /// Drop findings and clone groups that do not touch any changed file.
784    ///
785    /// Each project narrows its dead-code findings with its own config in
786    /// [`EditorAnalysisSession::apply_change_scope`], after the
787    /// type-aware pass. The scope must come after that pass, because the pass
788    /// reads `unused_files` as its set of unreachable files. This filter then
789    /// narrows the clone groups of the merged output. For the dead-code
790    /// findings, it changes nothing.
791    pub fn filter_by_changed_files(&mut self, changed_files: &FxHashSet<PathBuf>, root: &Path) {
792        fallow_engine::changed_files::filter_results_by_changed_files(
793            &mut self.results,
794            changed_files,
795        );
796        fallow_engine::changed_files::filter_duplication_by_changed_files(
797            &mut self.duplication,
798            changed_files,
799            root,
800        );
801    }
802}
803
804#[cfg(test)]
805pub(crate) mod tests {
806    use super::*;
807
808    use fallow_types::duplicates::{CloneFamily, CloneGroup, CloneInstance, DuplicationStats};
809
810    use super::editor_results::{
811        BoundaryViolation, BoundaryViolationFinding, CircularDependency, CircularDependencyFinding,
812        DevDependencyInProduction, DevDependencyInProductionFinding, ExportUsage, SecuritySeverity,
813        TestOnlyDependency, TestOnlyDependencyFinding, TypeOnlyDependency, UnlistedDependency,
814        UnlistedDependencyFinding, UnusedClassMemberFinding, UnusedDependency,
815        UnusedDependencyFinding, UnusedDevDependencyFinding, UnusedEnumMemberFinding, UnusedExport,
816        UnusedExportFinding, UnusedFile, UnusedFileFinding, UnusedMember,
817        UnusedOptionalDependencyFinding, UnusedStoreMemberFinding, UnusedTypeFinding,
818    };
819
820    #[test]
821    fn merges_duplication_stats_and_recomputes_percentage() {
822        let mut output = EditorAnalysisOutput {
823            duplication: EditorDuplicationReport {
824                clone_groups: vec![CloneGroup {
825                    instances: vec![CloneInstance {
826                        file: PathBuf::from("src/a.ts"),
827                        start_line: 1,
828                        end_line: 4,
829                        start_col: 0,
830                        end_col: 10,
831                        fragment: "const a = 1;".to_string(),
832                    }],
833                    token_count: 8,
834                    line_count: 4,
835                    similarity: None,
836                }],
837                clone_families: Vec::new(),
838                mirrored_directories: Vec::new(),
839                stats: DuplicationStats {
840                    clone_groups: 1,
841                    clone_families: 0,
842                    clone_instances: 1,
843                    total_files: 1,
844                    files_with_clones: 1,
845                    total_lines: 20,
846                    duplicated_lines: 4,
847                    total_tokens: 80,
848                    duplicated_tokens: 8,
849                    duplication_percentage: 20.0,
850                    clone_groups_below_min_occurrences: 1,
851                    clone_groups_ignored: 1,
852                    near_candidates_skipped: 2,
853                },
854            },
855            ..Default::default()
856        };
857
858        output.merge_duplication(EditorDuplicationReport {
859            clone_groups: Vec::new(),
860            clone_families: Vec::new(),
861            mirrored_directories: Vec::new(),
862            stats: DuplicationStats {
863                clone_groups: 0,
864                clone_families: 0,
865                clone_instances: 0,
866                total_files: 1,
867                files_with_clones: 0,
868                total_lines: 30,
869                duplicated_lines: 6,
870                total_tokens: 120,
871                duplicated_tokens: 12,
872                duplication_percentage: 20.0,
873                clone_groups_below_min_occurrences: 2,
874                clone_groups_ignored: 3,
875                near_candidates_skipped: 4,
876            },
877        });
878
879        assert_eq!(output.duplication.stats.total_lines, 50);
880        assert_eq!(output.duplication.stats.duplicated_lines, 10);
881        assert_eq!(
882            output.duplication.stats.clone_groups_below_min_occurrences,
883            3
884        );
885        assert_eq!(output.duplication.stats.clone_groups_ignored, 4);
886        assert_eq!(output.duplication.stats.near_candidates_skipped, 6);
887        assert!((output.duplication.stats.duplication_percentage - 20.0).abs() < f64::EPSILON);
888    }
889
890    #[test]
891    fn merging_duplication_keeps_the_family_corpus_count_aligned() {
892        let family = |path: &str| CloneFamily {
893            files: vec![PathBuf::from(path)],
894            groups: Vec::new(),
895            total_duplicated_lines: 4,
896            total_duplicated_tokens: 8,
897            suggestions: Vec::new(),
898        };
899        let report = |path: &str, families: usize| EditorDuplicationReport {
900            clone_groups: Vec::new(),
901            clone_families: vec![family(path)],
902            mirrored_directories: Vec::new(),
903            stats: DuplicationStats {
904                clone_families: families,
905                ..DuplicationStats::default()
906            },
907        };
908
909        let mut output = EditorAnalysisOutput {
910            duplication: report("src/a.ts", 3),
911            ..Default::default()
912        };
913        output.merge_duplication(report("src/b.ts", 2));
914
915        assert_eq!(output.duplication.stats.clone_families, 5);
916        assert_eq!(output.duplication.clone_families_shown(), 2);
917        assert_eq!(output.duplication.clone_families_omitted(), 3);
918        assert_eq!(
919            output.duplication.clone_families_total(),
920            output.duplication.stats.clone_families
921        );
922    }
923
924    #[test]
925    fn editor_session_returns_api_owned_project_output() {
926        let temp = tempfile::tempdir().expect("temp project");
927        let root = temp.path();
928        std::fs::create_dir_all(root.join("src")).expect("src dir");
929        std::fs::write(
930            root.join("package.json"),
931            r#"{"name":"editor-api-session","main":"src/index.ts"}"#,
932        )
933        .expect("package.json");
934        std::fs::write(
935            root.join("src/index.ts"),
936            "export const used = 1;\nconsole.log(used);\n",
937        )
938        .expect("source");
939
940        let session = EditorAnalysisSession::load(root, None).expect("session loads");
941        let output = session
942            .analyze_project_with(&fallow_config::DuplicatesConfig::default(), true)
943            .expect("analysis runs");
944
945        assert!(output.dead_code.modules.is_some());
946        assert!(
947            output
948                .dead_code
949                .files
950                .as_ref()
951                .is_some_and(|files| !files.is_empty())
952        );
953    }
954
955    /// The type-aware pass reads `unused_files` as its set of unreachable
956    /// files. So the analysis keeps an unused file outside the changed set,
957    /// and the scope removes it only when the caller applies it.
958    #[test]
959    fn changed_files_scope_runs_after_the_analysis_keeps_all_unused_files() {
960        let temp = tempfile::tempdir().expect("temp project");
961        let root = temp.path().canonicalize().expect("canonical root");
962        let src = root.join("src");
963        std::fs::create_dir_all(&src).expect("src dir");
964        std::fs::write(
965            root.join("package.json"),
966            r#"{"name":"editor-scope-order","main":"src/a.ts"}"#,
967        )
968        .expect("package.json");
969        std::fs::write(src.join("a.ts"), "export const a = 1;\n").expect("source a");
970        std::fs::write(src.join("orphan.ts"), "export const orphan = 1;\n").expect("orphan");
971
972        let session = EditorAnalysisSession::load(&root, None).expect("session loads");
973        let mut changed_files = FxHashSet::default();
974        changed_files.insert(src.join("a.ts"));
975        let mut output = session
976            .analyze_project_with_changed_files(
977                &fallow_config::DuplicatesConfig::default(),
978                false,
979                Some(&changed_files),
980            )
981            .expect("analysis runs");
982        let unused_files = |output: &EditorProjectAnalysisOutput| {
983            output
984                .dead_code
985                .results
986                .unused_files
987                .iter()
988                .map(|finding| finding.file.path.clone())
989                .collect::<Vec<_>>()
990        };
991        assert_eq!(
992            unused_files(&output),
993            vec![src.join("orphan.ts")],
994            "the analysis keeps the unused file outside the changed set"
995        );
996
997        session.apply_change_scope(&mut output, &ChangeScope::changed_files(&changed_files));
998        assert!(
999            unused_files(&output).is_empty(),
1000            "the scope removes the unused file outside the changed set: {:?}",
1001            unused_files(&output)
1002        );
1003    }
1004
1005    #[test]
1006    fn editor_session_scopes_duplication_to_changed_files() {
1007        let temp = tempfile::tempdir().expect("temp project");
1008        let root = temp.path();
1009        let src = root.join("src");
1010        std::fs::create_dir_all(&src).expect("src dir");
1011        std::fs::write(
1012            root.join("package.json"),
1013            r#"{"name":"editor-api-session","main":"src/a.ts"}"#,
1014        )
1015        .expect("package.json");
1016        let repeated =
1017            "export function repeated() {\n  return ['alpha', 'beta', 'gamma'].join(',');\n}\n";
1018        std::fs::write(src.join("a.ts"), repeated).expect("source a");
1019        std::fs::write(src.join("b.ts"), repeated).expect("source b");
1020
1021        let session = EditorAnalysisSession::load(root, None).expect("session loads");
1022        let mut config = session.config().duplicates.clone();
1023        config.min_tokens = 1;
1024        config.min_lines = 1;
1025        let full = session
1026            .analyze_project_with(&config, false)
1027            .expect("analysis runs");
1028        assert!(!full.duplication.clone_groups.is_empty());
1029
1030        let mut changed_files = FxHashSet::default();
1031        changed_files.insert(src.join("unrelated.ts"));
1032        let scoped = session
1033            .analyze_project_with_changed_files(&config, false, Some(&changed_files))
1034            .expect("analysis runs");
1035        assert!(scoped.duplication.clone_groups.is_empty());
1036    }
1037
1038    /// A function under a `health.thresholdOverrides` entry that raises the
1039    /// ceilings is not a health finding, so it gets no code lens either.
1040    #[test]
1041    fn inline_complexity_applies_health_threshold_overrides() {
1042        let temp = tempfile::tempdir().expect("temp project");
1043        let root = temp.path();
1044        std::fs::create_dir_all(root.join("src")).expect("src dir");
1045        std::fs::write(
1046            root.join("package.json"),
1047            r#"{"name":"editor-inline-overrides","main":"src/index.ts"}"#,
1048        )
1049        .expect("package.json");
1050        std::fs::write(
1051            root.join(".fallowrc.json"),
1052            r#"{"health":{"maxCyclomatic":2,"maxCognitive":2,"thresholdOverrides":[{"files":["src/legacy.ts"],"maxCyclomatic":50,"maxCognitive":50}]}}"#,
1053        )
1054        .expect("config");
1055        let branchy = |name: &str| {
1056            format!(
1057                "export function {name}(value: number): number {{\n  if (value > 1) {{ return 1; }}\n  \
1058                 if (value > 2) {{ return 2; }}\n  if (value > 3) {{ return 3; }}\n  return 0;\n}}\n"
1059            )
1060        };
1061        std::fs::write(root.join("src/app.ts"), branchy("appBranchy")).expect("app");
1062        std::fs::write(root.join("src/legacy.ts"), branchy("legacyBranchy")).expect("legacy");
1063        std::fs::write(
1064            root.join("src/index.ts"),
1065            "export { appBranchy } from \"./app\";\nexport { legacyBranchy } from \"./legacy\";\n",
1066        )
1067        .expect("index");
1068
1069        let session = EditorAnalysisSession::load(root, None).expect("session loads");
1070        let output = session
1071            .analyze_project_with(&session.config().duplicates.clone(), true)
1072            .expect("analysis runs");
1073        let names = collect_inline_complexity(session.config(), &output.dead_code)
1074            .into_iter()
1075            .map(|finding| finding.name)
1076            .collect::<Vec<_>>();
1077
1078        assert_eq!(
1079            names,
1080            vec!["appBranchy".to_string()],
1081            "the override raises the ceilings for src/legacy.ts, as in `fallow health`"
1082        );
1083    }
1084
1085    fn make_inline_finding(path: PathBuf) -> EditorInlineComplexityFinding {
1086        EditorInlineComplexityFinding {
1087            path,
1088            name: "myFn".to_string(),
1089            line: 1,
1090            col: 0,
1091            cyclomatic: 5,
1092            cognitive: 4,
1093            exceeded: EditorInlineComplexityExceeded::Cyclomatic,
1094        }
1095    }
1096
1097    #[test]
1098    fn filter_inline_complexity_keeps_findings_in_changed_set() {
1099        let changed: FxHashSet<PathBuf> = [PathBuf::from("/src/a.ts"), PathBuf::from("/src/b.ts")]
1100            .into_iter()
1101            .collect();
1102        let mut findings = vec![
1103            make_inline_finding(PathBuf::from("/src/a.ts")),
1104            make_inline_finding(PathBuf::from("/src/c.ts")),
1105        ];
1106
1107        filter_inline_complexity_by_changed_files(&mut findings, &changed);
1108
1109        assert_eq!(findings.len(), 1);
1110        assert_eq!(
1111            findings[0].path.to_string_lossy().replace('\\', "/"),
1112            "/src/a.ts"
1113        );
1114    }
1115
1116    #[test]
1117    fn filter_inline_complexity_removes_all_when_changed_set_empty() {
1118        let changed: FxHashSet<PathBuf> = FxHashSet::default();
1119        let mut findings = vec![make_inline_finding(PathBuf::from("/src/a.ts"))];
1120
1121        filter_inline_complexity_by_changed_files(&mut findings, &changed);
1122
1123        assert!(
1124            findings.is_empty(),
1125            "empty changed-files set must drop all inline complexity findings"
1126        );
1127    }
1128
1129    #[test]
1130    fn filter_inline_complexity_keeps_all_when_all_in_changed_set() {
1131        let path_a = PathBuf::from("/src/a.ts");
1132        let path_b = PathBuf::from("/src/b.ts");
1133        let changed: FxHashSet<PathBuf> = [path_a.clone(), path_b.clone()].into_iter().collect();
1134        let mut findings = vec![make_inline_finding(path_a), make_inline_finding(path_b)];
1135
1136        filter_inline_complexity_by_changed_files(&mut findings, &changed);
1137
1138        assert_eq!(
1139            findings.len(),
1140            2,
1141            "all findings in the changed set must be retained"
1142        );
1143    }
1144
1145    #[test]
1146    fn editor_session_applies_per_path_rule_overrides() {
1147        // The editor analysis path must resolve `overrides[].rules` the same
1148        // way the CLI does, so inline diagnostics and `fallow dead-code` agree
1149        // on which findings a project has turned off (issue #2621).
1150        let temp = tempfile::tempdir().expect("temp project");
1151        let root = temp.path();
1152        std::fs::create_dir_all(root.join("src/ui")).expect("ui dir");
1153        std::fs::create_dir_all(root.join("src/lib")).expect("lib dir");
1154        std::fs::write(
1155            root.join("package.json"),
1156            r#"{"name":"editor-override-rules","private":true,"main":"src/index.ts"}"#,
1157        )
1158        .expect("package.json");
1159        std::fs::write(
1160            root.join(".fallowrc.json"),
1161            r#"{
1162  "rules": { "unused-exports": "warn", "private-type-leaks": "warn" },
1163  "overrides": [
1164    {
1165      "files": ["src/ui/**"],
1166      "rules": { "unused-exports": "off", "private-type-leaks": "off" }
1167    }
1168  ]
1169}"#,
1170        )
1171        .expect("config");
1172        std::fs::write(
1173            root.join("src/index.ts"),
1174            "import { kitUsed } from './ui/kit';\nimport { libUsed } from './lib/util';\n\nexport const app = `${kitUsed}${libUsed}`;\n",
1175        )
1176        .expect("index");
1177        std::fs::write(
1178            root.join("src/ui/kit.ts"),
1179            "type Props = { label: string };\n\nexport const kitUsed = 'kit';\n\nexport const Unused = (props: Props) => props.label;\n",
1180        )
1181        .expect("kit");
1182        std::fs::write(
1183            root.join("src/lib/util.ts"),
1184            "type Internal = { id: string };\n\nexport const libUsed = 'lib';\n\nexport const alsoUnused = (value: Internal) => value.id;\n",
1185        )
1186        .expect("util");
1187
1188        let session = EditorAnalysisSession::load(root, None).expect("session loads");
1189        let output = session
1190            .analyze_project_with_changed_files(
1191                &fallow_config::DuplicatesConfig::default(),
1192                false,
1193                None,
1194            )
1195            .expect("analysis runs");
1196        let results = &output.dead_code.results;
1197
1198        let unused_export_paths = || {
1199            results
1200                .unused_exports
1201                .iter()
1202                .map(|finding| finding.export.path.clone())
1203                .collect::<Vec<_>>()
1204        };
1205        let leak_paths = || {
1206            results
1207                .private_type_leaks
1208                .iter()
1209                .map(|finding| finding.leak.path.clone())
1210                .collect::<Vec<_>>()
1211        };
1212
1213        assert!(
1214            !unused_export_paths()
1215                .iter()
1216                .any(|path| path.ends_with("kit.ts")),
1217            "the override turns unused-exports off for src/ui/**: {:?}",
1218            unused_export_paths()
1219        );
1220        assert!(
1221            !leak_paths().iter().any(|path| path.ends_with("kit.ts")),
1222            "the override turns private-type-leaks off for src/ui/**: {:?}",
1223            leak_paths()
1224        );
1225        assert!(
1226            unused_export_paths()
1227                .iter()
1228                .any(|path| path.ends_with("util.ts")),
1229            "paths outside the override keep their unused export: {:?}",
1230            unused_export_paths()
1231        );
1232        assert!(
1233            leak_paths().iter().any(|path| path.ends_with("util.ts")),
1234            "paths outside the override keep their private type leak: {:?}",
1235            leak_paths()
1236        );
1237    }
1238
1239    #[test]
1240    fn merge_results_covers_all_fields() {
1241        let mut output = EditorAnalysisOutput::default();
1242
1243        output.merge_results(merge_test_source_with_all_fields());
1244
1245        let target = &output.results;
1246
1247        assert_eq!(target.unused_files.len(), 1);
1248        assert_eq!(target.unused_exports.len(), 1);
1249        assert_eq!(target.unused_types.len(), 1);
1250        assert_eq!(target.private_type_leaks.len(), 1);
1251        assert_eq!(target.deprecated_exports_in_use.len(), 1);
1252        assert_eq!(target.unused_dependencies.len(), 1);
1253        assert_eq!(target.unused_dev_dependencies.len(), 1);
1254        assert_eq!(target.unused_optional_dependencies.len(), 1);
1255        assert_eq!(target.unused_enum_members.len(), 1);
1256        assert_eq!(target.unused_class_members.len(), 1);
1257        assert_eq!(target.unused_store_members.len(), 1);
1258        assert_eq!(target.unresolved_imports.len(), 1);
1259        assert_eq!(target.unlisted_dependencies.len(), 1);
1260        assert_eq!(target.duplicate_exports.len(), 1);
1261        assert_eq!(target.type_only_dependencies.len(), 1);
1262        assert_eq!(target.test_only_dependencies.len(), 1);
1263        assert_eq!(target.circular_dependencies.len(), 1);
1264        assert_eq!(target.re_export_cycles.len(), 1);
1265        assert_eq!(target.package_cycles.len(), 1);
1266        assert_eq!(target.boundary_violations.len(), 1);
1267        assert_eq!(target.boundary_call_violations.len(), 1);
1268        assert_eq!(target.policy_violations.len(), 1);
1269        assert_eq!(target.stale_suppressions.len(), 1);
1270        assert_eq!(target.unused_catalog_entries.len(), 1);
1271        assert_eq!(target.empty_catalog_groups.len(), 1);
1272        assert_eq!(target.unresolved_catalog_references.len(), 1);
1273        assert_eq!(target.unused_dependency_overrides.len(), 1);
1274        assert_eq!(target.misconfigured_dependency_overrides.len(), 1);
1275        assert_eq!(target.invalid_client_exports.len(), 1);
1276        assert_eq!(target.mixed_client_server_barrels.len(), 1);
1277        assert_eq!(target.misplaced_directives.len(), 1);
1278        assert_eq!(target.export_usages.len(), 1);
1279        assert_eq!(target.feature_flags.len(), 1);
1280        assert_eq!(target.security_findings.len(), 1);
1281        assert_eq!(target.security_unresolved_edge_files, 2);
1282        assert_eq!(target.security_unresolved_callee_diagnostics.len(), 1);
1283        assert_eq!(target.suppression_count, 1);
1284        assert!(target.entry_point_summary.is_some());
1285        assert_eq!(
1286            target
1287                .render_fan_in
1288                .as_ref()
1289                .and_then(|m| m.max_distinct_parents),
1290            Some(3)
1291        );
1292        assert_eq!(target.react_component_intel.len(), 1);
1293        assert_eq!(target.dev_dependencies_in_production.len(), 1);
1294        assert_eq!(target.boundary_coverage_violations.len(), 1);
1295        assert_eq!(target.route_collisions.len(), 1);
1296        assert_eq!(target.dynamic_segment_name_conflicts.len(), 1);
1297        assert_eq!(target.unprovided_injects.len(), 1);
1298        assert_eq!(target.unrendered_components.len(), 1);
1299        assert_eq!(target.unused_component_props.len(), 1);
1300        assert_eq!(target.unused_component_emits.len(), 1);
1301        assert_eq!(target.unused_component_inputs.len(), 1);
1302        assert_eq!(target.unused_component_outputs.len(), 1);
1303        assert_eq!(target.unused_svelte_events.len(), 1);
1304        assert_eq!(target.unused_server_actions.len(), 1);
1305        assert_eq!(target.unused_load_data_keys.len(), 1);
1306        assert!(target.unused_load_data_keys_global_abstain);
1307        assert_eq!(target.prop_drilling_chains.len(), 1);
1308        assert_eq!(target.thin_wrappers.len(), 1);
1309        assert_eq!(target.duplicate_prop_shapes.len(), 1);
1310        assert_eq!(target.active_suppressions.len(), 1);
1311        assert_eq!(target.semantic_framework_contracts.len(), 1);
1312        assert_eq!(target.security_unresolved_callee_sites, 3);
1313        assert_eq!(target.unused_component_props_exempted, 1);
1314    }
1315
1316    #[test]
1317    fn merge_duplication_recomputes_percentage() {
1318        let target = EditorDuplicationReport {
1319            clone_groups: vec![],
1320            clone_families: vec![],
1321            mirrored_directories: vec![],
1322            stats: DuplicationStats {
1323                total_files: 5,
1324                files_with_clones: 1,
1325                total_lines: 200,
1326                duplicated_lines: 20,
1327                total_tokens: 1000,
1328                duplicated_tokens: 100,
1329                clone_groups: 1,
1330                clone_families: 0,
1331                clone_instances: 2,
1332                duplication_percentage: 10.0, // 20/200 * 100
1333                clone_groups_below_min_occurrences: 0,
1334                clone_groups_ignored: 0,
1335                near_candidates_skipped: 0,
1336            },
1337        };
1338        let source = EditorDuplicationReport {
1339            clone_groups: vec![],
1340            clone_families: vec![],
1341            mirrored_directories: vec![],
1342            stats: DuplicationStats {
1343                total_files: 3,
1344                files_with_clones: 1,
1345                total_lines: 300,
1346                duplicated_lines: 60,
1347                total_tokens: 1500,
1348                duplicated_tokens: 300,
1349                clone_groups: 2,
1350                clone_families: 0,
1351                clone_instances: 4,
1352                duplication_percentage: 20.0, // 60/300 * 100
1353                clone_groups_below_min_occurrences: 0,
1354                clone_groups_ignored: 0,
1355                near_candidates_skipped: 0,
1356            },
1357        };
1358
1359        let mut output = EditorAnalysisOutput::new(EditorAnalysisResults::default(), target);
1360        output.merge_duplication(source);
1361
1362        let target = &output.duplication;
1363        assert_eq!(target.stats.total_files, 8);
1364        assert_eq!(target.stats.files_with_clones, 2);
1365        assert_eq!(target.stats.total_lines, 500);
1366        assert_eq!(target.stats.duplicated_lines, 80);
1367        assert_eq!(target.stats.total_tokens, 2500);
1368        assert_eq!(target.stats.duplicated_tokens, 400);
1369        assert_eq!(target.stats.clone_groups, 3);
1370        assert_eq!(target.stats.clone_instances, 6);
1371        assert!((target.stats.duplication_percentage - 16.0).abs() < f64::EPSILON);
1372    }
1373
1374    #[test]
1375    fn merge_duplication_zero_total_lines_yields_zero_percentage() {
1376        let mut output = EditorAnalysisOutput::default();
1377
1378        output.merge_duplication(EditorDuplicationReport::default());
1379
1380        let target = &output.duplication;
1381
1382        assert_eq!(target.stats.total_lines, 0);
1383        assert!((target.stats.duplication_percentage - 0.0).abs() < f64::EPSILON);
1384    }
1385
1386    fn merge_test_unused_export(
1387        path: &str,
1388        export_name: &str,
1389        is_type_only: bool,
1390        line: u32,
1391    ) -> UnusedExport {
1392        UnusedExport {
1393            path: path.into(),
1394            export_name: export_name.to_string(),
1395            is_type_only,
1396            line,
1397            col: 0,
1398            span_start: 0,
1399            is_re_export: false,
1400            deprecated: false,
1401            deprecated_reason: None,
1402        }
1403    }
1404
1405    fn merge_test_unused_dependency(
1406        package_name: &str,
1407        location: super::editor_results::DependencyLocation,
1408        line: u32,
1409    ) -> UnusedDependency {
1410        UnusedDependency {
1411            package_name: package_name.to_string(),
1412            location,
1413            path: "/pkg.json".into(),
1414            line,
1415            used_in_workspaces: Vec::new(),
1416        }
1417    }
1418
1419    fn merge_test_unused_member(
1420        parent_name: &str,
1421        member_name: &str,
1422        kind: super::editor_extract::MemberKind,
1423        line: u32,
1424    ) -> UnusedMember {
1425        UnusedMember {
1426            path: "/f.ts".into(),
1427            parent_name: parent_name.to_string(),
1428            member_name: member_name.to_string(),
1429            kind,
1430            line,
1431            col: 0,
1432        }
1433    }
1434
1435    #[expect(
1436        clippy::too_many_lines,
1437        reason = "intentionally names every EditorAnalysisResults field (no ..Default::default()) so a new field is a compile error here; see #444"
1438    )]
1439    pub fn merge_test_source_with_all_fields() -> EditorAnalysisResults {
1440        EditorAnalysisResults {
1441            unused_files: vec![UnusedFileFinding::with_actions(UnusedFile {
1442                path: "/f.ts".into(),
1443            })],
1444            unused_exports: vec![UnusedExportFinding::with_actions(merge_test_unused_export(
1445                "/f.ts", "e", false, 1,
1446            ))],
1447            unused_types: vec![UnusedTypeFinding::with_actions(merge_test_unused_export(
1448                "/f.ts", "T", true, 2,
1449            ))],
1450            unused_dependencies: vec![UnusedDependencyFinding::with_actions(
1451                merge_test_unused_dependency(
1452                    "dep",
1453                    super::editor_results::DependencyLocation::Dependencies,
1454                    3,
1455                ),
1456            )],
1457            unused_dev_dependencies: vec![UnusedDevDependencyFinding::with_actions(
1458                merge_test_unused_dependency(
1459                    "dev-dep",
1460                    super::editor_results::DependencyLocation::DevDependencies,
1461                    4,
1462                ),
1463            )],
1464            unused_optional_dependencies: vec![UnusedOptionalDependencyFinding::with_actions(
1465                merge_test_unused_dependency(
1466                    "opt-dep",
1467                    super::editor_results::DependencyLocation::OptionalDependencies,
1468                    5,
1469                ),
1470            )],
1471            unused_enum_members: vec![UnusedEnumMemberFinding::with_actions(
1472                merge_test_unused_member(
1473                    "E",
1474                    "A",
1475                    super::editor_extract::MemberKind::EnumMember,
1476                    6,
1477                ),
1478            )],
1479            unused_class_members: vec![UnusedClassMemberFinding::with_actions(
1480                merge_test_unused_member(
1481                    "C",
1482                    "m",
1483                    super::editor_extract::MemberKind::ClassMethod,
1484                    7,
1485                ),
1486            )],
1487            unused_store_members: vec![UnusedStoreMemberFinding::with_actions(
1488                merge_test_unused_member(
1489                    "S",
1490                    "a",
1491                    super::editor_extract::MemberKind::StoreMember,
1492                    7,
1493                ),
1494            )],
1495            unresolved_imports: vec![
1496                super::editor_results::UnresolvedImportFinding::with_actions(
1497                    super::editor_results::UnresolvedImport {
1498                        path: "/f.ts".into(),
1499                        specifier: "./gone".to_string(),
1500                        line: 8,
1501                        col: 0,
1502                        specifier_col: 10,
1503                    },
1504                ),
1505            ],
1506            unlisted_dependencies: vec![UnlistedDependencyFinding::with_actions(
1507                UnlistedDependency {
1508                    package_name: "unlisted".to_string(),
1509                    imported_from: vec![],
1510                },
1511            )],
1512            duplicate_exports: vec![super::editor_results::DuplicateExportFinding::with_actions(
1513                super::editor_results::DuplicateExport {
1514                    export_name: "dup".to_string(),
1515                    locations: vec![],
1516                },
1517            )],
1518            type_only_dependencies: vec![
1519                super::editor_results::TypeOnlyDependencyFinding::with_actions(
1520                    TypeOnlyDependency {
1521                        package_name: "type-only".to_string(),
1522                        path: "/pkg.json".into(),
1523                        line: 9,
1524                    },
1525                ),
1526            ],
1527            circular_dependencies: vec![CircularDependencyFinding::with_actions(
1528                CircularDependency {
1529                    files: vec!["/a.ts".into(), "/b.ts".into()],
1530                    length: 2,
1531                    line: 10,
1532                    col: 0,
1533                    edges: Vec::new(),
1534                    is_cross_package: false,
1535                },
1536            )],
1537            test_only_dependencies: vec![TestOnlyDependencyFinding::with_actions(
1538                TestOnlyDependency {
1539                    package_name: "test-only".to_string(),
1540                    path: "/pkg.json".into(),
1541                    line: 11,
1542                },
1543            )],
1544            dev_dependencies_in_production: vec![DevDependencyInProductionFinding::with_actions(
1545                DevDependencyInProduction {
1546                    package_name: "dev-in-prod".to_string(),
1547                    path: "/pkg.json".into(),
1548                    line: 12,
1549                },
1550            )],
1551            boundary_violations: vec![BoundaryViolationFinding::with_actions(BoundaryViolation {
1552                from_path: "/a.ts".into(),
1553                to_path: "/b.ts".into(),
1554                from_zone: "ui".to_string(),
1555                to_zone: "data".to_string(),
1556                import_specifier: "../data/db".to_string(),
1557                line: 12,
1558                col: 0,
1559                via_path: None,
1560            })],
1561            boundary_coverage_violations: vec![
1562                super::editor_results::BoundaryCoverageViolationFinding::with_actions(
1563                    super::editor_results::BoundaryCoverageViolation {
1564                        path: "/unzoned.ts".into(),
1565                        line: 13,
1566                        col: 0,
1567                    },
1568                ),
1569            ],
1570            boundary_call_violations: vec![
1571                super::editor_results::BoundaryCallViolationFinding::with_actions(
1572                    super::editor_results::BoundaryCallViolation {
1573                        path: "/zoned.ts".into(),
1574                        line: 14,
1575                        col: 0,
1576                        zone: "domain".to_string(),
1577                        callee: "console.log".to_string(),
1578                        pattern: "console.*".to_string(),
1579                    },
1580                ),
1581            ],
1582            policy_violations: vec![super::editor_results::PolicyViolationFinding::with_actions(
1583                super::editor_results::PolicyViolation {
1584                    path: "/zoned.ts".into(),
1585                    line: 15,
1586                    col: 0,
1587                    pack: "team-policy".to_string(),
1588                    rule_id: "no-console".to_string(),
1589                    kind: super::editor_results::PolicyRuleKind::BannedCall,
1590                    matched: "console.log".to_string(),
1591                    severity: super::editor_results::PolicyViolationSeverity::Warn,
1592                    message: None,
1593                },
1594            )],
1595            export_usages: vec![ExportUsage {
1596                path: "/f.ts".into(),
1597                export_name: "used".to_string(),
1598                line: 15,
1599                col: 0,
1600                reference_count: 3,
1601                reference_locations: vec![],
1602            }],
1603            private_type_leaks: vec![super::editor_results::PrivateTypeLeakFinding::with_actions(
1604                super::editor_results::PrivateTypeLeak {
1605                    path: "/f.ts".into(),
1606                    export_name: "pub_fn".to_string(),
1607                    type_name: "Secret".to_string(),
1608                    line: 14,
1609                    col: 0,
1610                    span_start: 0,
1611                    semantic: None,
1612                },
1613            )],
1614            deprecated_exports_in_use: vec![
1615                super::editor_results::DeprecatedExportInUseFinding::with_actions(
1616                    super::editor_results::DeprecatedExportInUse {
1617                        path: "/f.ts".into(),
1618                        export_name: "old".to_string(),
1619                        is_type_only: false,
1620                        line: 16,
1621                        col: 0,
1622                        span_start: 0,
1623                        deprecated_reason: None,
1624                        consumer_count: 1,
1625                        consumers: vec![super::editor_results::DeprecatedExportConsumer {
1626                            path: "/g.ts".into(),
1627                            line: 1,
1628                            col: 0,
1629                            kind: super::editor_results::DeprecatedConsumerKind::NamedImport,
1630                        }],
1631                        public_api: false,
1632                    },
1633                ),
1634            ],
1635            re_export_cycles: vec![super::editor_results::ReExportCycleFinding::with_actions(
1636                super::editor_results::ReExportCycle {
1637                    files: vec!["/barrel.ts".into()],
1638                    kind: super::editor_results::ReExportCycleKind::SelfLoop,
1639                },
1640            )],
1641            package_cycles: vec![super::editor_results::PackageCycleFinding::with_actions(
1642                super::editor_results::PackageCycle {
1643                    packages: vec!["a".into(), "b".into()],
1644                    package_roots: Vec::new(),
1645                    length: 2,
1646                    edges: vec![super::editor_results::PackageCycleEdge {
1647                        from_package: "a".into(),
1648                        to_package: "b".into(),
1649                        path: "/a/x.ts".into(),
1650                        target_path: "/b/y.ts".into(),
1651                        line: 1,
1652                        col: 0,
1653                        type_only: false,
1654                    }],
1655                    group_truncated: false,
1656                },
1657            )],
1658            stale_suppressions: vec![super::editor_results::StaleSuppression {
1659                finding_id: None,
1660                path: "/f.ts".into(),
1661                line: 15,
1662                col: 0,
1663                origin: super::editor_results::SuppressionOrigin::Comment {
1664                    issue_kind: None,
1665                    reason: None,
1666                    is_file_level: false,
1667                    kind_known: true,
1668                },
1669                missing_reason: false,
1670                actions: super::editor_results::StaleSuppression::actions_for(false),
1671                effective_severity: None,
1672            }],
1673            unused_catalog_entries: vec![
1674                super::editor_results::UnusedCatalogEntryFinding::with_actions(
1675                    super::editor_results::UnusedCatalogEntry {
1676                        entry_name: "react".to_string(),
1677                        catalog_name: "default".to_string(),
1678                        path: "/pnpm-workspace.yaml".into(),
1679                        line: 16,
1680                        hardcoded_consumers: vec![],
1681                    },
1682                ),
1683            ],
1684            empty_catalog_groups: vec![
1685                super::editor_results::EmptyCatalogGroupFinding::with_actions(
1686                    super::editor_results::EmptyCatalogGroup {
1687                        catalog_name: "ui".to_string(),
1688                        path: "/pnpm-workspace.yaml".into(),
1689                        line: 17,
1690                    },
1691                ),
1692            ],
1693            unresolved_catalog_references: vec![
1694                super::editor_results::UnresolvedCatalogReferenceFinding::with_actions(
1695                    super::editor_results::UnresolvedCatalogReference {
1696                        entry_name: "vue".to_string(),
1697                        catalog_name: "default".to_string(),
1698                        path: "/pkg.json".into(),
1699                        line: 18,
1700                        available_in_catalogs: vec![],
1701                    },
1702                ),
1703            ],
1704            unused_dependency_overrides: vec![
1705                super::editor_results::UnusedDependencyOverrideFinding::with_actions(
1706                    super::editor_results::UnusedDependencyOverride {
1707                        raw_key: "react".to_string(),
1708                        target_package: "react".to_string(),
1709                        parent_package: None,
1710                        version_constraint: None,
1711                        version_range: "18".to_string(),
1712                        source: super::editor_results::DependencyOverrideSource::PnpmWorkspaceYaml,
1713                        path: "/pnpm-workspace.yaml".into(),
1714                        line: 19,
1715                        hint: None,
1716                    },
1717                ),
1718            ],
1719            misconfigured_dependency_overrides: vec![
1720                super::editor_results::MisconfiguredDependencyOverrideFinding::with_actions(
1721                    super::editor_results::MisconfiguredDependencyOverride {
1722                        raw_key: "bad>".to_string(),
1723                        target_package: None,
1724                        raw_value: String::new(),
1725                        reason:
1726                            super::editor_results::DependencyOverrideMisconfigReason::EmptyValue,
1727                        source: super::editor_results::DependencyOverrideSource::PnpmPackageJson,
1728                        path: "/pkg.json".into(),
1729                        line: 20,
1730                    },
1731                ),
1732            ],
1733            invalid_client_exports: vec![
1734                super::editor_results::InvalidClientExportFinding::with_actions(
1735                    super::editor_results::InvalidClientExport {
1736                        path: "/app/page.tsx".into(),
1737                        export_name: "metadata".to_string(),
1738                        directive: "use client".to_string(),
1739                        line: 22,
1740                        col: 0,
1741                    },
1742                ),
1743            ],
1744            mixed_client_server_barrels: vec![
1745                super::editor_results::MixedClientServerBarrelFinding::with_actions(
1746                    super::editor_results::MixedClientServerBarrel {
1747                        path: "/app/components/index.ts".into(),
1748                        client_origin: "./Button".to_string(),
1749                        server_origin: "./fetchUser".to_string(),
1750                        line: 23,
1751                        col: 0,
1752                    },
1753                ),
1754            ],
1755            misplaced_directives: vec![
1756                super::editor_results::MisplacedDirectiveFinding::with_actions(
1757                    super::editor_results::MisplacedDirective {
1758                        path: "/app/widget.tsx".into(),
1759                        directive: "use client".to_string(),
1760                        line: 24,
1761                        col: 0,
1762                    },
1763                ),
1764            ],
1765            unprovided_injects: vec![
1766                super::editor_results::UnprovidedInjectFinding::with_actions(
1767                    super::editor_results::UnprovidedInject {
1768                        path: "/Comp.vue".into(),
1769                        key_name: "ApiKey".to_string(),
1770                        framework: "vue".to_string(),
1771                        line: 25,
1772                        col: 0,
1773                    },
1774                ),
1775            ],
1776            unrendered_components: vec![
1777                super::editor_results::UnrenderedComponentFinding::with_actions(
1778                    super::editor_results::UnrenderedComponent {
1779                        path: "/Widget.vue".into(),
1780                        component_name: "Widget".to_string(),
1781                        framework: "vue".to_string(),
1782                        reachable_via: None,
1783                        line: 26,
1784                        col: 0,
1785                    },
1786                ),
1787            ],
1788            unused_component_props: vec![
1789                super::editor_results::UnusedComponentPropFinding::with_actions(
1790                    super::editor_results::UnusedComponentProp {
1791                        path: "/Widget.vue".into(),
1792                        component_name: "Widget".to_string(),
1793                        prop_name: "size".to_string(),
1794                        line: 27,
1795                        col: 0,
1796                    },
1797                ),
1798            ],
1799            unused_component_emits: vec![
1800                super::editor_results::UnusedComponentEmitFinding::with_actions(
1801                    super::editor_results::UnusedComponentEmit {
1802                        path: "/Widget.vue".into(),
1803                        component_name: "Widget".to_string(),
1804                        emit_name: "change".to_string(),
1805                        line: 28,
1806                        col: 0,
1807                    },
1808                ),
1809            ],
1810            unused_component_inputs: vec![
1811                super::editor_results::UnusedComponentInputFinding::with_actions(
1812                    super::editor_results::UnusedComponentInput {
1813                        path: "/widget.component.ts".into(),
1814                        component_name: "WidgetComponent".to_string(),
1815                        input_name: "size".to_string(),
1816                        line: 29,
1817                        col: 0,
1818                    },
1819                ),
1820            ],
1821            unused_component_outputs: vec![
1822                super::editor_results::UnusedComponentOutputFinding::with_actions(
1823                    super::editor_results::UnusedComponentOutput {
1824                        path: "/widget.component.ts".into(),
1825                        component_name: "WidgetComponent".to_string(),
1826                        output_name: "change".to_string(),
1827                        line: 30,
1828                        col: 0,
1829                    },
1830                ),
1831            ],
1832            unused_svelte_events: vec![
1833                super::editor_results::UnusedSvelteEventFinding::with_actions(
1834                    super::editor_results::UnusedSvelteEvent {
1835                        path: "/Child.svelte".into(),
1836                        component_name: "Child".to_string(),
1837                        event_name: "dead".to_string(),
1838                        line: 31,
1839                        col: 0,
1840                    },
1841                ),
1842            ],
1843            unused_server_actions: vec![
1844                super::editor_results::UnusedServerActionFinding::with_actions(
1845                    super::editor_results::UnusedServerAction {
1846                        path: "/app/actions.ts".into(),
1847                        action_name: "createUser".to_string(),
1848                        line: 32,
1849                        col: 0,
1850                    },
1851                ),
1852            ],
1853            unused_load_data_keys: vec![
1854                super::editor_results::UnusedLoadDataKeyFinding::with_actions(
1855                    super::editor_results::UnusedLoadDataKey {
1856                        path: "/src/routes/blog/+page.server.ts".into(),
1857                        key_name: "posts".to_string(),
1858                        line: 33,
1859                        col: 0,
1860                        route_dir: None,
1861                    },
1862                ),
1863            ],
1864            unused_load_data_keys_global_abstain: true,
1865            prop_drilling_chains: vec![
1866                super::editor_results::PropDrillingChainFinding::with_actions(
1867                    super::editor_results::PropDrillingChain {
1868                        prop: "user".to_string(),
1869                        depth: 1,
1870                        hops: vec![super::editor_results::PropDrillHop {
1871                            file: "/Hop.tsx".into(),
1872                            line: 34,
1873                            component: "Hop".to_string(),
1874                        }],
1875                    },
1876                ),
1877            ],
1878            thin_wrappers: vec![super::editor_results::ThinWrapperFinding::with_actions(
1879                super::editor_results::ThinWrapper {
1880                    file: "/Wrapper.tsx".into(),
1881                    line: 35,
1882                    component: "Wrapper".to_string(),
1883                    child_component: "Child".to_string(),
1884                },
1885            )],
1886            duplicate_prop_shapes: vec![
1887                super::editor_results::DuplicatePropShapeFinding::with_actions(
1888                    super::editor_results::DuplicatePropShape {
1889                        file: "/Card.tsx".into(),
1890                        line: 36,
1891                        component: "Card".to_string(),
1892                        shape: vec!["subtitle".to_string(), "title".to_string()],
1893                        group_size: 2,
1894                        sharing_components: vec![],
1895                    },
1896                ),
1897            ],
1898            route_collisions: vec![super::editor_results::RouteCollisionFinding::with_actions(
1899                super::editor_results::RouteCollision {
1900                    path: "/app/(a)/about/page.tsx".into(),
1901                    url: "/about".to_string(),
1902                    conflicting_paths: vec!["/app/(b)/about/page.tsx".into()],
1903                    line: 1,
1904                    col: 0,
1905                },
1906            )],
1907            dynamic_segment_name_conflicts: vec![
1908                super::editor_results::DynamicSegmentNameConflictFinding::with_actions(
1909                    super::editor_results::DynamicSegmentNameConflict {
1910                        path: "/app/shop/[id]/page.tsx".into(),
1911                        position: "/shop".to_string(),
1912                        conflicting_segments: vec!["[id]".to_string(), "[slug]".to_string()],
1913                        conflicting_paths: vec!["/app/shop/[slug]/edit/page.tsx".into()],
1914                        line: 1,
1915                        col: 0,
1916                    },
1917                ),
1918            ],
1919            suppression_count: 1,
1920            unused_component_props_exempted: 1,
1921            active_suppressions: vec![super::editor_results::ActiveSuppression {
1922                path: "/f.ts".into(),
1923                kind: Some("unused-export".to_string()),
1924                is_file_level: false,
1925                reason: None,
1926                comment_line: 37,
1927            }],
1928            feature_flags: vec![super::editor_results::FeatureFlag {
1929                path: "/f.ts".into(),
1930                flag_name: "ENABLE_X".to_string(),
1931                kind: super::editor_results::FlagKind::EnvironmentVariable,
1932                confidence: super::editor_results::FlagConfidence::High,
1933                line: 21,
1934                col: 0,
1935                guard_span_start: None,
1936                guard_span_end: None,
1937                sdk_name: None,
1938                guard_line_start: None,
1939                guard_line_end: None,
1940                guarded_dead_exports: vec![],
1941            }],
1942            entry_point_summary: Some(super::editor_results::EntryPointSummary {
1943                total: 0,
1944                by_source: vec![],
1945            }),
1946            security_findings: vec![super::editor_results::SecurityFinding {
1947                finding_id: String::new(),
1948                candidate: super::editor_results::SecurityCandidate::default(),
1949                taint_flow: None,
1950                attack_surface: None,
1951                kind: super::editor_results::SecurityFindingKind::ClientServerLeak,
1952                category: None,
1953                cwe: None,
1954                path: "/client.tsx".into(),
1955                line: 1,
1956                col: 0,
1957                evidence: "transitively reaches DATABASE_URL".to_string(),
1958                source_backed: false,
1959                source_read: None,
1960                severity: SecuritySeverity::Low,
1961                trace: vec![],
1962                actions: vec![],
1963                dead_code: None,
1964                reachability: None,
1965                runtime: None,
1966            }],
1967            security_unresolved_edge_files: 2,
1968            security_unresolved_callee_sites: 3,
1969            security_unresolved_callee_diagnostics: vec![
1970                super::editor_results::SecurityUnresolvedCalleeDiagnostic {
1971                    path: "/client.tsx".into(),
1972                    line: 2,
1973                    col: 0,
1974                    reason: super::editor_extract::SkippedSecurityCalleeReason::DynamicDispatch,
1975                    expression_kind:
1976                        super::editor_extract::SkippedSecurityCalleeExpressionKind::Other,
1977                },
1978            ],
1979            render_fan_in: Some(super::editor_results::RenderFanInMetric {
1980                per_component: vec![super::editor_results::RenderFanInComponent {
1981                    file: "/Button.tsx".into(),
1982                    component: "Button".to_string(),
1983                    render_sites: 6,
1984                    distinct_parents: 3,
1985                }],
1986                p95_distinct_parents: Some(3),
1987                high_pct: Some(0.0),
1988                max_distinct_parents: Some(3),
1989            }),
1990            react_component_intel: vec![super::editor_results::ReactComponentIntel {
1991                path: "/Button.tsx".into(),
1992                component_name: "Button".to_string(),
1993                anchor_line: 1,
1994                anchor_col: 0,
1995                render_sites: 6,
1996                distinct_parents: 3,
1997                prop_count: 1,
1998                hooks: super::editor_results::ReactHookSummary::default(),
1999                props: Vec::new(),
2000            }],
2001            semantic_framework_contracts: vec![fallow_types::semantic::SemanticFrameworkContract {
2002                framework: "lit".to_string(),
2003                package: "lit".to_string(),
2004                heritage_symbol: "LitElement".to_string(),
2005                heritage_names: vec!["LitElement".to_string()],
2006                relation: fallow_types::semantic::SemanticFrameworkRelation::Extends,
2007                members: vec!["render".to_string()],
2008            }],
2009        }
2010    }
2011}