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