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