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 still runs with full graph context, and the dead-code
624    /// findings keep full scope until the type-aware pass has run. That pass
625    /// reads `unused_files` as its set of unreachable files. After the pass,
626    /// call [`Self::apply_change_scope`] to narrow the dead-code findings and
627    /// clone groups of this project.
628    ///
629    /// # Errors
630    ///
631    /// Returns an engine error when dead-code parsing or analysis fails.
632    pub fn analyze_project_with_changed_files(
633        &self,
634        duplicates_config: &fallow_config::DuplicatesConfig,
635        retain_complexity_artifacts: bool,
636        changed_files: Option<&FxHashSet<PathBuf>>,
637    ) -> fallow_engine::EngineResult<EditorProjectAnalysisOutput> {
638        self.inner
639            .analyze_project_with_artifacts(
640                duplicates_config,
641                fallow_engine::project_analysis::ProjectAnalysisArtifactOptions {
642                    retain_complexity_artifacts,
643                    changed_files: changed_files.cloned(),
644                    ..fallow_engine::project_analysis::ProjectAnalysisArtifactOptions::default()
645                },
646            )
647            .map(fallow_engine::project_analysis::ProjectAnalysisArtifacts::into_output)
648            .map(EditorProjectAnalysisOutput::from_engine)
649            .map(|output| self.with_resolved_rule_severities(output))
650    }
651
652    /// Narrow the dead-code findings and clone groups of this project to a
653    /// resolved change scope, with the engine functions that the CLI, MCP and
654    /// Node API use.
655    ///
656    /// Call it after the type-aware pass. That pass reads `unused_files` as
657    /// its set of unreachable files, so a scope before it drops evidence from
658    /// unused files outside the changed set. A multi-root editor session
659    /// merges several projects, and each project has its own
660    /// `ignoreFindings`. So the scope runs per project, where the config is
661    /// known, and not after the merge.
662    pub fn apply_change_scope(
663        &self,
664        output: &mut EditorProjectAnalysisOutput,
665        scope: &ChangeScope,
666    ) {
667        fallow_engine::dead_code::apply_scope(
668            &mut output.dead_code.results,
669            &fallow_engine::dead_code::DeadCodeScope {
670                workspace_roots: None,
671                changes: Some(scope),
672                diff: None,
673                files: None,
674            },
675            self.inner.config(),
676        );
677        fallow_engine::duplicates::apply_scope(
678            &mut output.duplication,
679            &fallow_engine::duplicates::DuplicationScope {
680                changes: Some(scope),
681                diff: None,
682                workspace_roots: None,
683            },
684            &self.inner.config().root,
685        );
686    }
687
688    /// Resolve configured rule severities, including per-path
689    /// `overrides[].rules`, against a freshly analyzed project slice.
690    ///
691    /// Each project root is filtered with its own config before a multi-root
692    /// editor session merges the outputs, so an override only ever applies to
693    /// the project that declares it.
694    fn with_resolved_rule_severities(
695        &self,
696        mut output: EditorProjectAnalysisOutput,
697    ) -> EditorProjectAnalysisOutput {
698        fallow_engine::dead_code::apply_rule_severities(
699            &mut output.dead_code.results,
700            self.inner.config(),
701        );
702        output
703    }
704
705    const fn from_engine(inner: fallow_engine::session::AnalysisSession) -> Self {
706        Self { inner }
707    }
708}
709
710/// Dead-code and duplication project output owned by the editor API boundary.
711#[derive(Debug)]
712pub struct EditorProjectAnalysisOutput {
713    /// Dead-code findings plus optionally retained parse artifacts.
714    pub dead_code: EditorDeadCodeAnalysisOutput,
715    /// Duplication report for the analyzed project slice.
716    pub duplication: EditorDuplicationReport,
717}
718
719impl EditorProjectAnalysisOutput {
720    fn from_engine(output: fallow_engine::project_analysis::ProjectAnalysisOutput) -> Self {
721        Self {
722            dead_code: EditorDeadCodeAnalysisOutput::from_engine(output.dead_code),
723            duplication: output.duplication,
724        }
725    }
726}
727
728/// Dead-code and duplication output shaped for editor integrations.
729#[derive(Debug, Default)]
730pub struct EditorAnalysisOutput {
731    /// Typed dead-code findings.
732    pub results: EditorAnalysisResults,
733    /// Duplication report.
734    pub duplication: EditorDuplicationReport,
735}
736
737impl EditorAnalysisOutput {
738    /// Pair dead-code results with a duplication report.
739    #[must_use]
740    pub const fn new(results: EditorAnalysisResults, duplication: EditorDuplicationReport) -> Self {
741        Self {
742            results,
743            duplication,
744        }
745    }
746
747    /// Merge another project analysis output into this accumulated output.
748    pub fn merge_project_output(&mut self, output: EditorProjectAnalysisOutput) {
749        self.merge_results(output.dead_code.results);
750        self.merge_duplication(output.duplication);
751    }
752
753    /// Merge another dead-code results set into this one.
754    pub fn merge_results(&mut self, source: EditorAnalysisResults) {
755        self.results.merge_into(source);
756    }
757
758    /// Merge another duplication report into this one, summing the aggregate
759    /// stats and recomputing the duplication percentage over the union.
760    pub fn merge_duplication(&mut self, source: EditorDuplicationReport) {
761        self.duplication.clone_groups.extend(source.clone_groups);
762        self.duplication
763            .clone_families
764            .extend(source.clone_families);
765        self.duplication
766            .mirrored_directories
767            .extend(source.mirrored_directories);
768        self.duplication.stats.clone_groups += source.stats.clone_groups;
769        self.duplication.stats.clone_families += source.stats.clone_families;
770        self.duplication.stats.clone_instances += source.stats.clone_instances;
771        self.duplication.stats.total_files += source.stats.total_files;
772        self.duplication.stats.files_with_clones += source.stats.files_with_clones;
773        self.duplication.stats.total_lines += source.stats.total_lines;
774        self.duplication.stats.duplicated_lines += source.stats.duplicated_lines;
775        self.duplication.stats.total_tokens += source.stats.total_tokens;
776        self.duplication.stats.duplicated_tokens += source.stats.duplicated_tokens;
777        self.duplication.stats.clone_groups_below_min_occurrences +=
778            source.stats.clone_groups_below_min_occurrences;
779        self.duplication.stats.clone_groups_ignored += source.stats.clone_groups_ignored;
780        self.duplication.stats.near_candidates_skipped += source.stats.near_candidates_skipped;
781        self.duplication.stats.duplication_percentage = if self.duplication.stats.total_lines > 0 {
782            (self.duplication.stats.duplicated_lines as f64
783                / self.duplication.stats.total_lines as f64)
784                * 100.0
785        } else {
786            0.0
787        };
788    }
789
790    /// Drop findings and clone groups that do not touch any changed file.
791    ///
792    /// Each project narrows its dead-code findings with its own config in
793    /// [`EditorAnalysisSession::apply_change_scope`], after the
794    /// type-aware pass. The scope must come after that pass, because the pass
795    /// reads `unused_files` as its set of unreachable files. This filter then
796    /// narrows the clone groups of the merged output. For the dead-code
797    /// findings, it changes nothing.
798    pub fn filter_by_changed_files(&mut self, changed_files: &FxHashSet<PathBuf>, root: &Path) {
799        fallow_engine::changed_files::filter_results_by_changed_files(
800            &mut self.results,
801            changed_files,
802        );
803        fallow_engine::changed_files::filter_duplication_by_changed_files(
804            &mut self.duplication,
805            changed_files,
806            root,
807        );
808    }
809}
810
811#[cfg(test)]
812pub(crate) mod tests {
813    use super::*;
814
815    use fallow_types::duplicates::{CloneFamily, CloneGroup, CloneInstance, DuplicationStats};
816
817    use super::editor_results::{
818        BoundaryViolation, BoundaryViolationFinding, CircularDependency, CircularDependencyFinding,
819        DevDependencyInProduction, DevDependencyInProductionFinding, ExportUsage, SecuritySeverity,
820        TestOnlyDependency, TestOnlyDependencyFinding, TypeOnlyDependency, UnlistedDependency,
821        UnlistedDependencyFinding, UnusedClassMemberFinding, UnusedDependency,
822        UnusedDependencyFinding, UnusedDevDependencyFinding, UnusedEnumMemberFinding, UnusedExport,
823        UnusedExportFinding, UnusedFile, UnusedFileFinding, UnusedMember,
824        UnusedOptionalDependencyFinding, UnusedStoreMemberFinding, UnusedTypeFinding,
825    };
826
827    #[test]
828    fn merges_duplication_stats_and_recomputes_percentage() {
829        let mut output = EditorAnalysisOutput {
830            duplication: EditorDuplicationReport {
831                clone_groups: vec![CloneGroup {
832                    instances: vec![CloneInstance {
833                        is_symlink: false,
834                        file: PathBuf::from("src/a.ts"),
835                        start_line: 1,
836                        end_line: 4,
837                        start_col: 0,
838                        end_col: 10,
839                        fragment: "const a = 1;".to_string(),
840                    }],
841                    token_count: 8,
842                    line_count: 4,
843                    similarity: None,
844                }],
845                clone_families: Vec::new(),
846                mirrored_directories: Vec::new(),
847                stats: DuplicationStats {
848                    clone_groups: 1,
849                    clone_families: 0,
850                    clone_instances: 1,
851                    total_files: 1,
852                    files_with_clones: 1,
853                    total_lines: 20,
854                    duplicated_lines: 4,
855                    total_tokens: 80,
856                    duplicated_tokens: 8,
857                    duplication_percentage: 20.0,
858                    clone_groups_below_min_occurrences: 1,
859                    clone_groups_ignored: 1,
860                    near_candidates_skipped: 2,
861                },
862            },
863            ..Default::default()
864        };
865
866        output.merge_duplication(EditorDuplicationReport {
867            clone_groups: Vec::new(),
868            clone_families: Vec::new(),
869            mirrored_directories: Vec::new(),
870            stats: DuplicationStats {
871                clone_groups: 0,
872                clone_families: 0,
873                clone_instances: 0,
874                total_files: 1,
875                files_with_clones: 0,
876                total_lines: 30,
877                duplicated_lines: 6,
878                total_tokens: 120,
879                duplicated_tokens: 12,
880                duplication_percentage: 20.0,
881                clone_groups_below_min_occurrences: 2,
882                clone_groups_ignored: 3,
883                near_candidates_skipped: 4,
884            },
885        });
886
887        assert_eq!(output.duplication.stats.total_lines, 50);
888        assert_eq!(output.duplication.stats.duplicated_lines, 10);
889        assert_eq!(
890            output.duplication.stats.clone_groups_below_min_occurrences,
891            3
892        );
893        assert_eq!(output.duplication.stats.clone_groups_ignored, 4);
894        assert_eq!(output.duplication.stats.near_candidates_skipped, 6);
895        assert!((output.duplication.stats.duplication_percentage - 20.0).abs() < f64::EPSILON);
896    }
897
898    #[test]
899    fn merging_duplication_keeps_the_family_corpus_count_aligned() {
900        let family = |path: &str| CloneFamily {
901            files: vec![PathBuf::from(path)],
902            groups: Vec::new(),
903            total_duplicated_lines: 4,
904            total_duplicated_tokens: 8,
905            suggestions: Vec::new(),
906        };
907        let report = |path: &str, families: usize| EditorDuplicationReport {
908            clone_groups: Vec::new(),
909            clone_families: vec![family(path)],
910            mirrored_directories: Vec::new(),
911            stats: DuplicationStats {
912                clone_families: families,
913                ..DuplicationStats::default()
914            },
915        };
916
917        let mut output = EditorAnalysisOutput {
918            duplication: report("src/a.ts", 3),
919            ..Default::default()
920        };
921        output.merge_duplication(report("src/b.ts", 2));
922
923        assert_eq!(output.duplication.stats.clone_families, 5);
924        assert_eq!(output.duplication.clone_families_shown(), 2);
925        assert_eq!(output.duplication.clone_families_omitted(), 3);
926        assert_eq!(
927            output.duplication.clone_families_total(),
928            output.duplication.stats.clone_families
929        );
930    }
931
932    #[test]
933    fn editor_session_returns_api_owned_project_output() {
934        let temp = tempfile::tempdir().expect("temp project");
935        let root = temp.path();
936        std::fs::create_dir_all(root.join("src")).expect("src dir");
937        std::fs::write(
938            root.join("package.json"),
939            r#"{"name":"editor-api-session","main":"src/index.ts"}"#,
940        )
941        .expect("package.json");
942        std::fs::write(
943            root.join("src/index.ts"),
944            "export const used = 1;\nconsole.log(used);\n",
945        )
946        .expect("source");
947
948        let session = EditorAnalysisSession::load(root, None).expect("session loads");
949        let output = session
950            .analyze_project_with(&fallow_config::DuplicatesConfig::default(), true)
951            .expect("analysis runs");
952
953        assert!(output.dead_code.modules.is_some());
954        assert!(
955            output
956                .dead_code
957                .files
958                .as_ref()
959                .is_some_and(|files| !files.is_empty())
960        );
961    }
962
963    /// The type-aware pass reads `unused_files` as its set of unreachable
964    /// files. So the analysis keeps an unused file outside the changed set,
965    /// and the scope removes it only when the caller applies it.
966    #[test]
967    fn changed_files_scope_runs_after_the_analysis_keeps_all_unused_files() {
968        let temp = tempfile::tempdir().expect("temp project");
969        let root = temp.path().canonicalize().expect("canonical root");
970        let src = root.join("src");
971        std::fs::create_dir_all(&src).expect("src dir");
972        std::fs::write(
973            root.join("package.json"),
974            r#"{"name":"editor-scope-order","main":"src/a.ts"}"#,
975        )
976        .expect("package.json");
977        std::fs::write(src.join("a.ts"), "export const a = 1;\n").expect("source a");
978        std::fs::write(src.join("orphan.ts"), "export const orphan = 1;\n").expect("orphan");
979
980        let session = EditorAnalysisSession::load(&root, None).expect("session loads");
981        let mut changed_files = FxHashSet::default();
982        changed_files.insert(src.join("a.ts"));
983        let mut output = session
984            .analyze_project_with_changed_files(
985                &fallow_config::DuplicatesConfig::default(),
986                false,
987                Some(&changed_files),
988            )
989            .expect("analysis runs");
990        let unused_files = |output: &EditorProjectAnalysisOutput| {
991            output
992                .dead_code
993                .results
994                .unused_files
995                .iter()
996                .map(|finding| finding.file.path.clone())
997                .collect::<Vec<_>>()
998        };
999        assert_eq!(
1000            unused_files(&output),
1001            vec![src.join("orphan.ts")],
1002            "the analysis keeps the unused file outside the changed set"
1003        );
1004
1005        session.apply_change_scope(&mut output, &ChangeScope::changed_files(&changed_files));
1006        assert!(
1007            unused_files(&output).is_empty(),
1008            "the scope removes the unused file outside the changed set: {:?}",
1009            unused_files(&output)
1010        );
1011    }
1012
1013    #[test]
1014    fn editor_session_scopes_duplication_to_changed_files() {
1015        let temp = tempfile::tempdir().expect("temp project");
1016        let root = temp.path();
1017        let src = root.join("src");
1018        std::fs::create_dir_all(&src).expect("src dir");
1019        std::fs::write(
1020            root.join("package.json"),
1021            r#"{"name":"editor-api-session","main":"src/a.ts"}"#,
1022        )
1023        .expect("package.json");
1024        let repeated =
1025            "export function repeated() {\n  return ['alpha', 'beta', 'gamma'].join(',');\n}\n";
1026        std::fs::write(src.join("a.ts"), repeated).expect("source a");
1027        std::fs::write(src.join("b.ts"), repeated).expect("source b");
1028
1029        let session = EditorAnalysisSession::load(root, None).expect("session loads");
1030        let mut config = session.config().duplicates.clone();
1031        config.min_tokens = 1;
1032        config.min_lines = 1;
1033        let full = session
1034            .analyze_project_with(&config, false)
1035            .expect("analysis runs");
1036        assert!(!full.duplication.clone_groups.is_empty());
1037
1038        let mut changed_files = FxHashSet::default();
1039        changed_files.insert(src.join("unrelated.ts"));
1040        let scoped = session
1041            .analyze_project_with_changed_files(&config, false, Some(&changed_files))
1042            .expect("analysis runs");
1043        assert!(scoped.duplication.clone_groups.is_empty());
1044    }
1045
1046    /// A function under a `health.thresholdOverrides` entry that raises the
1047    /// ceilings is not a health finding, so it gets no code lens either.
1048    #[test]
1049    fn inline_complexity_applies_health_threshold_overrides() {
1050        let temp = tempfile::tempdir().expect("temp project");
1051        let root = temp.path();
1052        std::fs::create_dir_all(root.join("src")).expect("src dir");
1053        std::fs::write(
1054            root.join("package.json"),
1055            r#"{"name":"editor-inline-overrides","main":"src/index.ts"}"#,
1056        )
1057        .expect("package.json");
1058        std::fs::write(
1059            root.join(".fallowrc.json"),
1060            r#"{"health":{"maxCyclomatic":2,"maxCognitive":2,"thresholdOverrides":[{"files":["src/legacy.ts"],"maxCyclomatic":50,"maxCognitive":50}]}}"#,
1061        )
1062        .expect("config");
1063        let branchy = |name: &str| {
1064            format!(
1065                "export function {name}(value: number): number {{\n  if (value > 1) {{ return 1; }}\n  \
1066                 if (value > 2) {{ return 2; }}\n  if (value > 3) {{ return 3; }}\n  return 0;\n}}\n"
1067            )
1068        };
1069        std::fs::write(root.join("src/app.ts"), branchy("appBranchy")).expect("app");
1070        std::fs::write(root.join("src/legacy.ts"), branchy("legacyBranchy")).expect("legacy");
1071        std::fs::write(
1072            root.join("src/index.ts"),
1073            "export { appBranchy } from \"./app\";\nexport { legacyBranchy } from \"./legacy\";\n",
1074        )
1075        .expect("index");
1076
1077        let session = EditorAnalysisSession::load(root, None).expect("session loads");
1078        let output = session
1079            .analyze_project_with(&session.config().duplicates.clone(), true)
1080            .expect("analysis runs");
1081        let names = collect_inline_complexity(session.config(), &output.dead_code)
1082            .into_iter()
1083            .map(|finding| finding.name)
1084            .collect::<Vec<_>>();
1085
1086        assert_eq!(
1087            names,
1088            vec!["appBranchy".to_string()],
1089            "the override raises the ceilings for src/legacy.ts, as in `fallow health`"
1090        );
1091    }
1092
1093    fn make_inline_finding(path: PathBuf) -> EditorInlineComplexityFinding {
1094        EditorInlineComplexityFinding {
1095            path,
1096            name: "myFn".to_string(),
1097            line: 1,
1098            col: 0,
1099            cyclomatic: 5,
1100            cognitive: 4,
1101            exceeded: EditorInlineComplexityExceeded::Cyclomatic,
1102        }
1103    }
1104
1105    #[test]
1106    fn filter_inline_complexity_keeps_findings_in_changed_set() {
1107        let changed: FxHashSet<PathBuf> = [PathBuf::from("/src/a.ts"), PathBuf::from("/src/b.ts")]
1108            .into_iter()
1109            .collect();
1110        let mut findings = vec![
1111            make_inline_finding(PathBuf::from("/src/a.ts")),
1112            make_inline_finding(PathBuf::from("/src/c.ts")),
1113        ];
1114
1115        filter_inline_complexity_by_changed_files(&mut findings, &changed);
1116
1117        assert_eq!(findings.len(), 1);
1118        assert_eq!(
1119            findings[0].path.to_string_lossy().replace('\\', "/"),
1120            "/src/a.ts"
1121        );
1122    }
1123
1124    #[test]
1125    fn filter_inline_complexity_removes_all_when_changed_set_empty() {
1126        let changed: FxHashSet<PathBuf> = FxHashSet::default();
1127        let mut findings = vec![make_inline_finding(PathBuf::from("/src/a.ts"))];
1128
1129        filter_inline_complexity_by_changed_files(&mut findings, &changed);
1130
1131        assert!(
1132            findings.is_empty(),
1133            "empty changed-files set must drop all inline complexity findings"
1134        );
1135    }
1136
1137    #[test]
1138    fn filter_inline_complexity_keeps_all_when_all_in_changed_set() {
1139        let path_a = PathBuf::from("/src/a.ts");
1140        let path_b = PathBuf::from("/src/b.ts");
1141        let changed: FxHashSet<PathBuf> = [path_a.clone(), path_b.clone()].into_iter().collect();
1142        let mut findings = vec![make_inline_finding(path_a), make_inline_finding(path_b)];
1143
1144        filter_inline_complexity_by_changed_files(&mut findings, &changed);
1145
1146        assert_eq!(
1147            findings.len(),
1148            2,
1149            "all findings in the changed set must be retained"
1150        );
1151    }
1152
1153    #[test]
1154    fn editor_session_applies_per_path_rule_overrides() {
1155        // The editor analysis path must resolve `overrides[].rules` the same
1156        // way the CLI does, so inline diagnostics and `fallow dead-code` agree
1157        // on which findings a project has turned off (issue #2621).
1158        let temp = tempfile::tempdir().expect("temp project");
1159        let root = temp.path();
1160        std::fs::create_dir_all(root.join("src/ui")).expect("ui dir");
1161        std::fs::create_dir_all(root.join("src/lib")).expect("lib dir");
1162        std::fs::write(
1163            root.join("package.json"),
1164            r#"{"name":"editor-override-rules","private":true,"main":"src/index.ts"}"#,
1165        )
1166        .expect("package.json");
1167        std::fs::write(
1168            root.join(".fallowrc.json"),
1169            r#"{
1170  "rules": { "unused-exports": "warn", "private-type-leaks": "warn" },
1171  "overrides": [
1172    {
1173      "files": ["src/ui/**"],
1174      "rules": { "unused-exports": "off", "private-type-leaks": "off" }
1175    }
1176  ]
1177}"#,
1178        )
1179        .expect("config");
1180        std::fs::write(
1181            root.join("src/index.ts"),
1182            "import { kitUsed } from './ui/kit';\nimport { libUsed } from './lib/util';\n\nexport const app = `${kitUsed}${libUsed}`;\n",
1183        )
1184        .expect("index");
1185        std::fs::write(
1186            root.join("src/ui/kit.ts"),
1187            "type Props = { label: string };\n\nexport const kitUsed = 'kit';\n\nexport const Unused = (props: Props) => props.label;\n",
1188        )
1189        .expect("kit");
1190        std::fs::write(
1191            root.join("src/lib/util.ts"),
1192            "type Internal = { id: string };\n\nexport const libUsed = 'lib';\n\nexport const alsoUnused = (value: Internal) => value.id;\n",
1193        )
1194        .expect("util");
1195
1196        let session = EditorAnalysisSession::load(root, None).expect("session loads");
1197        let output = session
1198            .analyze_project_with_changed_files(
1199                &fallow_config::DuplicatesConfig::default(),
1200                false,
1201                None,
1202            )
1203            .expect("analysis runs");
1204        let results = &output.dead_code.results;
1205
1206        let unused_export_paths = || {
1207            results
1208                .unused_exports
1209                .iter()
1210                .map(|finding| finding.export.path.clone())
1211                .collect::<Vec<_>>()
1212        };
1213        let leak_paths = || {
1214            results
1215                .private_type_leaks
1216                .iter()
1217                .map(|finding| finding.leak.path.clone())
1218                .collect::<Vec<_>>()
1219        };
1220
1221        assert!(
1222            !unused_export_paths()
1223                .iter()
1224                .any(|path| path.ends_with("kit.ts")),
1225            "the override turns unused-exports off for src/ui/**: {:?}",
1226            unused_export_paths()
1227        );
1228        assert!(
1229            !leak_paths().iter().any(|path| path.ends_with("kit.ts")),
1230            "the override turns private-type-leaks off for src/ui/**: {:?}",
1231            leak_paths()
1232        );
1233        assert!(
1234            unused_export_paths()
1235                .iter()
1236                .any(|path| path.ends_with("util.ts")),
1237            "paths outside the override keep their unused export: {:?}",
1238            unused_export_paths()
1239        );
1240        assert!(
1241            leak_paths().iter().any(|path| path.ends_with("util.ts")),
1242            "paths outside the override keep their private type leak: {:?}",
1243            leak_paths()
1244        );
1245    }
1246
1247    #[test]
1248    fn merge_results_covers_all_fields() {
1249        let mut output = EditorAnalysisOutput::default();
1250
1251        output.merge_results(merge_test_source_with_all_fields());
1252
1253        let target = &output.results;
1254
1255        assert_eq!(target.unused_files.len(), 1);
1256        assert_eq!(target.unused_exports.len(), 1);
1257        assert_eq!(target.unused_types.len(), 1);
1258        assert_eq!(target.private_type_leaks.len(), 1);
1259        assert_eq!(target.deprecated_exports_in_use.len(), 1);
1260        assert_eq!(target.unused_dependencies.len(), 1);
1261        assert_eq!(target.unused_dev_dependencies.len(), 1);
1262        assert_eq!(target.unused_optional_dependencies.len(), 1);
1263        assert_eq!(target.unused_enum_members.len(), 1);
1264        assert_eq!(target.unused_class_members.len(), 1);
1265        assert_eq!(target.unused_store_members.len(), 1);
1266        assert_eq!(target.unresolved_imports.len(), 1);
1267        assert_eq!(target.unlisted_dependencies.len(), 1);
1268        assert_eq!(target.duplicate_exports.len(), 1);
1269        assert_eq!(target.type_only_dependencies.len(), 1);
1270        assert_eq!(target.test_only_dependencies.len(), 1);
1271        assert_eq!(target.circular_dependencies.len(), 1);
1272        assert_eq!(target.re_export_cycles.len(), 1);
1273        assert_eq!(target.package_cycles.len(), 1);
1274        assert_eq!(target.boundary_violations.len(), 1);
1275        assert_eq!(target.boundary_call_violations.len(), 1);
1276        assert_eq!(target.policy_violations.len(), 1);
1277        assert_eq!(target.stale_suppressions.len(), 1);
1278        assert_eq!(target.unused_catalog_entries.len(), 1);
1279        assert_eq!(target.empty_catalog_groups.len(), 1);
1280        assert_eq!(target.unresolved_catalog_references.len(), 1);
1281        assert_eq!(target.unused_dependency_overrides.len(), 1);
1282        assert_eq!(target.misconfigured_dependency_overrides.len(), 1);
1283        assert_eq!(target.invalid_client_exports.len(), 1);
1284        assert_eq!(target.mixed_client_server_barrels.len(), 1);
1285        assert_eq!(target.misplaced_directives.len(), 1);
1286        assert_eq!(target.export_usages.len(), 1);
1287        assert_eq!(target.feature_flags.len(), 1);
1288        assert_eq!(target.security_findings.len(), 1);
1289        assert_eq!(target.security_unresolved_edge_files, 2);
1290        assert_eq!(target.security_unresolved_callee_diagnostics.len(), 1);
1291        assert_eq!(target.suppression_count, 1);
1292        assert_eq!(target.cascade_hidden, 1);
1293        assert_eq!(target.cascade.suppressed_unused_files.len(), 1);
1294        assert!(target.entry_point_summary.is_some());
1295        assert_eq!(
1296            target
1297                .render_fan_in
1298                .as_ref()
1299                .and_then(|m| m.max_distinct_parents),
1300            Some(3)
1301        );
1302        assert_eq!(target.react_component_intel.len(), 1);
1303        assert_eq!(target.dev_dependencies_in_production.len(), 1);
1304        assert_eq!(target.boundary_coverage_violations.len(), 1);
1305        assert_eq!(target.route_collisions.len(), 1);
1306        assert_eq!(target.dynamic_segment_name_conflicts.len(), 1);
1307        assert_eq!(target.unprovided_injects.len(), 1);
1308        assert_eq!(target.unrendered_components.len(), 1);
1309        assert_eq!(target.unused_component_props.len(), 1);
1310        let candidate = &target.absent_component_props[0];
1311        assert_eq!(candidate.prop.component_name, "Widget");
1312        assert_eq!(candidate.prop.prop_name, "highlight");
1313        assert!(candidate.prop.has_default);
1314        assert_eq!(candidate.prop.framework, "vue");
1315        assert_eq!(
1316            candidate.prop.inspected_call_sites[0].path,
1317            PathBuf::from("/main.vue")
1318        );
1319        assert_eq!(candidate.prop.explanation, "Review optional prop contract");
1320
1321        assert_eq!(target.unused_component_emits.len(), 1);
1322        assert_eq!(target.unused_component_inputs.len(), 1);
1323        assert_eq!(target.unused_component_outputs.len(), 1);
1324        assert_eq!(target.unused_svelte_events.len(), 1);
1325        assert_eq!(target.unused_effect_layers.len(), 1);
1326        assert_eq!(target.unused_tagged_errors.len(), 1);
1327        assert_eq!(target.unproduced_catch_tags.len(), 1);
1328        assert_eq!(target.unused_server_actions.len(), 1);
1329        assert_eq!(target.unused_load_data_keys.len(), 1);
1330        assert!(target.unused_load_data_keys_global_abstain);
1331        assert_eq!(target.prop_drilling_chains.len(), 1);
1332        assert_eq!(target.thin_wrappers.len(), 1);
1333        assert_eq!(target.duplicate_prop_shapes.len(), 1);
1334        assert_eq!(target.active_suppressions.len(), 1);
1335        assert_eq!(target.semantic_framework_contracts.len(), 1);
1336        assert_eq!(target.security_unresolved_callee_sites, 3);
1337        assert_eq!(target.unused_component_props_exempted, 1);
1338    }
1339
1340    #[test]
1341    fn merge_duplication_recomputes_percentage() {
1342        let target = EditorDuplicationReport {
1343            clone_groups: vec![],
1344            clone_families: vec![],
1345            mirrored_directories: vec![],
1346            stats: DuplicationStats {
1347                total_files: 5,
1348                files_with_clones: 1,
1349                total_lines: 200,
1350                duplicated_lines: 20,
1351                total_tokens: 1000,
1352                duplicated_tokens: 100,
1353                clone_groups: 1,
1354                clone_families: 0,
1355                clone_instances: 2,
1356                duplication_percentage: 10.0, // 20/200 * 100
1357                clone_groups_below_min_occurrences: 0,
1358                clone_groups_ignored: 0,
1359                near_candidates_skipped: 0,
1360            },
1361        };
1362        let source = EditorDuplicationReport {
1363            clone_groups: vec![],
1364            clone_families: vec![],
1365            mirrored_directories: vec![],
1366            stats: DuplicationStats {
1367                total_files: 3,
1368                files_with_clones: 1,
1369                total_lines: 300,
1370                duplicated_lines: 60,
1371                total_tokens: 1500,
1372                duplicated_tokens: 300,
1373                clone_groups: 2,
1374                clone_families: 0,
1375                clone_instances: 4,
1376                duplication_percentage: 20.0, // 60/300 * 100
1377                clone_groups_below_min_occurrences: 0,
1378                clone_groups_ignored: 0,
1379                near_candidates_skipped: 0,
1380            },
1381        };
1382
1383        let mut output = EditorAnalysisOutput::new(EditorAnalysisResults::default(), target);
1384        output.merge_duplication(source);
1385
1386        let target = &output.duplication;
1387        assert_eq!(target.stats.total_files, 8);
1388        assert_eq!(target.stats.files_with_clones, 2);
1389        assert_eq!(target.stats.total_lines, 500);
1390        assert_eq!(target.stats.duplicated_lines, 80);
1391        assert_eq!(target.stats.total_tokens, 2500);
1392        assert_eq!(target.stats.duplicated_tokens, 400);
1393        assert_eq!(target.stats.clone_groups, 3);
1394        assert_eq!(target.stats.clone_instances, 6);
1395        assert!((target.stats.duplication_percentage - 16.0).abs() < f64::EPSILON);
1396    }
1397
1398    #[test]
1399    fn merge_duplication_zero_total_lines_yields_zero_percentage() {
1400        let mut output = EditorAnalysisOutput::default();
1401
1402        output.merge_duplication(EditorDuplicationReport::default());
1403
1404        let target = &output.duplication;
1405
1406        assert_eq!(target.stats.total_lines, 0);
1407        assert!((target.stats.duplication_percentage - 0.0).abs() < f64::EPSILON);
1408    }
1409
1410    fn merge_test_unused_export(
1411        path: &str,
1412        export_name: &str,
1413        is_type_only: bool,
1414        line: u32,
1415    ) -> UnusedExport {
1416        UnusedExport {
1417            path: path.into(),
1418            export_name: export_name.to_string(),
1419            is_type_only,
1420            line,
1421            col: 0,
1422            span_start: 0,
1423            is_re_export: false,
1424            deprecated: false,
1425            deprecated_reason: None,
1426        }
1427    }
1428
1429    fn merge_test_unused_dependency(
1430        package_name: &str,
1431        location: super::editor_results::DependencyLocation,
1432        line: u32,
1433    ) -> UnusedDependency {
1434        UnusedDependency {
1435            package_name: package_name.to_string(),
1436            location,
1437            path: "/pkg.json".into(),
1438            line,
1439            used_in_workspaces: Vec::new(),
1440            declared_and_imported_in: Vec::new(),
1441        }
1442    }
1443
1444    fn merge_test_unused_member(
1445        parent_name: &str,
1446        member_name: &str,
1447        kind: super::editor_extract::MemberKind,
1448        line: u32,
1449    ) -> UnusedMember {
1450        UnusedMember {
1451            path: "/f.ts".into(),
1452            parent_name: parent_name.to_string(),
1453            member_name: member_name.to_string(),
1454            kind,
1455            line,
1456            col: 0,
1457        }
1458    }
1459
1460    #[expect(
1461        clippy::too_many_lines,
1462        reason = "intentionally names every EditorAnalysisResults field (no ..Default::default()) so a new field is a compile error here; see #444"
1463    )]
1464    pub fn merge_test_source_with_all_fields() -> EditorAnalysisResults {
1465        EditorAnalysisResults {
1466            unused_files: vec![UnusedFileFinding::with_actions(UnusedFile {
1467                path: "/f.ts".into(),
1468            })],
1469            unused_exports: vec![UnusedExportFinding::with_actions(merge_test_unused_export(
1470                "/f.ts", "e", false, 1,
1471            ))],
1472            unused_types: vec![UnusedTypeFinding::with_actions(merge_test_unused_export(
1473                "/f.ts", "T", true, 2,
1474            ))],
1475            unused_dependencies: vec![UnusedDependencyFinding::with_actions(
1476                merge_test_unused_dependency(
1477                    "dep",
1478                    super::editor_results::DependencyLocation::Dependencies,
1479                    3,
1480                ),
1481            )],
1482            unused_dev_dependencies: vec![UnusedDevDependencyFinding::with_actions(
1483                merge_test_unused_dependency(
1484                    "dev-dep",
1485                    super::editor_results::DependencyLocation::DevDependencies,
1486                    4,
1487                ),
1488            )],
1489            unused_optional_dependencies: vec![UnusedOptionalDependencyFinding::with_actions(
1490                merge_test_unused_dependency(
1491                    "opt-dep",
1492                    super::editor_results::DependencyLocation::OptionalDependencies,
1493                    5,
1494                ),
1495            )],
1496            unused_enum_members: vec![UnusedEnumMemberFinding::with_actions(
1497                merge_test_unused_member(
1498                    "E",
1499                    "A",
1500                    super::editor_extract::MemberKind::EnumMember,
1501                    6,
1502                ),
1503            )],
1504            unused_class_members: vec![UnusedClassMemberFinding::with_actions(
1505                merge_test_unused_member(
1506                    "C",
1507                    "m",
1508                    super::editor_extract::MemberKind::ClassMethod,
1509                    7,
1510                ),
1511            )],
1512            unused_store_members: vec![UnusedStoreMemberFinding::with_actions(
1513                merge_test_unused_member(
1514                    "S",
1515                    "a",
1516                    super::editor_extract::MemberKind::StoreMember,
1517                    7,
1518                ),
1519            )],
1520            unresolved_imports: vec![
1521                super::editor_results::UnresolvedImportFinding::with_actions(
1522                    super::editor_results::UnresolvedImport {
1523                        path: "/f.ts".into(),
1524                        specifier: "./gone".to_string(),
1525                        line: 8,
1526                        col: 0,
1527                        specifier_col: 10,
1528                    },
1529                ),
1530            ],
1531            unlisted_dependencies: vec![UnlistedDependencyFinding::with_actions(
1532                UnlistedDependency {
1533                    package_name: "unlisted".to_string(),
1534                    imported_from: vec![],
1535                },
1536            )],
1537            duplicate_exports: vec![super::editor_results::DuplicateExportFinding::with_actions(
1538                super::editor_results::DuplicateExport {
1539                    export_name: "dup".to_string(),
1540                    locations: vec![],
1541                },
1542            )],
1543            type_only_dependencies: vec![
1544                super::editor_results::TypeOnlyDependencyFinding::with_actions(
1545                    TypeOnlyDependency {
1546                        package_name: "type-only".to_string(),
1547                        path: "/pkg.json".into(),
1548                        line: 9,
1549                    },
1550                ),
1551            ],
1552            circular_dependencies: vec![CircularDependencyFinding::with_actions(
1553                CircularDependency {
1554                    files: vec!["/a.ts".into(), "/b.ts".into()],
1555                    length: 2,
1556                    line: 10,
1557                    col: 0,
1558                    edges: Vec::new(),
1559                    is_cross_package: false,
1560                },
1561            )],
1562            test_only_dependencies: vec![TestOnlyDependencyFinding::with_actions(
1563                TestOnlyDependency {
1564                    package_name: "test-only".to_string(),
1565                    path: "/pkg.json".into(),
1566                    line: 11,
1567                },
1568            )],
1569            dev_dependencies_in_production: vec![DevDependencyInProductionFinding::with_actions(
1570                DevDependencyInProduction {
1571                    package_name: "dev-in-prod".to_string(),
1572                    path: "/pkg.json".into(),
1573                    line: 12,
1574                },
1575            )],
1576            boundary_violations: vec![BoundaryViolationFinding::with_actions(BoundaryViolation {
1577                from_path: "/a.ts".into(),
1578                to_path: "/b.ts".into(),
1579                from_zone: "ui".to_string(),
1580                to_zone: "data".to_string(),
1581                import_specifier: "../data/db".to_string(),
1582                line: 12,
1583                col: 0,
1584                via_path: None,
1585            })],
1586            boundary_coverage_violations: vec![
1587                super::editor_results::BoundaryCoverageViolationFinding::with_actions(
1588                    super::editor_results::BoundaryCoverageViolation {
1589                        path: "/unzoned.ts".into(),
1590                        line: 13,
1591                        col: 0,
1592                    },
1593                ),
1594            ],
1595            boundary_call_violations: vec![
1596                super::editor_results::BoundaryCallViolationFinding::with_actions(
1597                    super::editor_results::BoundaryCallViolation {
1598                        path: "/zoned.ts".into(),
1599                        line: 14,
1600                        col: 0,
1601                        zone: "domain".to_string(),
1602                        callee: "console.log".to_string(),
1603                        pattern: "console.*".to_string(),
1604                    },
1605                ),
1606            ],
1607            policy_violations: vec![super::editor_results::PolicyViolationFinding::with_actions(
1608                super::editor_results::PolicyViolation {
1609                    path: "/zoned.ts".into(),
1610                    line: 15,
1611                    col: 0,
1612                    pack: "team-policy".to_string(),
1613                    rule_id: "no-console".to_string(),
1614                    kind: super::editor_results::PolicyRuleKind::BannedCall,
1615                    matched: "console.log".to_string(),
1616                    severity: super::editor_results::PolicyViolationSeverity::Warn,
1617                    message: None,
1618                },
1619            )],
1620            export_usages: vec![ExportUsage {
1621                path: "/f.ts".into(),
1622                export_name: "used".to_string(),
1623                line: 15,
1624                col: 0,
1625                reference_count: 3,
1626                reference_locations: vec![],
1627            }],
1628            private_type_leaks: vec![super::editor_results::PrivateTypeLeakFinding::with_actions(
1629                super::editor_results::PrivateTypeLeak {
1630                    path: "/f.ts".into(),
1631                    export_name: "pub_fn".to_string(),
1632                    type_name: "Secret".to_string(),
1633                    line: 14,
1634                    col: 0,
1635                    span_start: 0,
1636                    semantic: None,
1637                },
1638            )],
1639            deprecated_exports_in_use: vec![
1640                super::editor_results::DeprecatedExportInUseFinding::with_actions(
1641                    super::editor_results::DeprecatedExportInUse {
1642                        path: "/f.ts".into(),
1643                        export_name: "old".to_string(),
1644                        is_type_only: false,
1645                        line: 16,
1646                        col: 0,
1647                        span_start: 0,
1648                        deprecated_reason: None,
1649                        consumer_count: 1,
1650                        consumers: vec![super::editor_results::DeprecatedExportConsumer {
1651                            path: "/g.ts".into(),
1652                            line: 1,
1653                            col: 0,
1654                            kind: super::editor_results::DeprecatedConsumerKind::NamedImport,
1655                        }],
1656                        public_api: false,
1657                    },
1658                ),
1659            ],
1660            re_export_cycles: vec![super::editor_results::ReExportCycleFinding::with_actions(
1661                super::editor_results::ReExportCycle {
1662                    files: vec!["/barrel.ts".into()],
1663                    kind: super::editor_results::ReExportCycleKind::SelfLoop,
1664                },
1665            )],
1666            package_cycles: vec![super::editor_results::PackageCycleFinding::with_actions(
1667                super::editor_results::PackageCycle {
1668                    packages: vec!["a".into(), "b".into()],
1669                    package_roots: Vec::new(),
1670                    length: 2,
1671                    edges: vec![super::editor_results::PackageCycleEdge {
1672                        from_package: "a".into(),
1673                        to_package: "b".into(),
1674                        path: "/a/x.ts".into(),
1675                        target_path: "/b/y.ts".into(),
1676                        line: 1,
1677                        col: 0,
1678                        type_only: false,
1679                    }],
1680                    group_truncated: false,
1681                },
1682            )],
1683            stale_suppressions: vec![super::editor_results::StaleSuppression {
1684                finding_id: None,
1685                path: "/f.ts".into(),
1686                line: 15,
1687                col: 0,
1688                origin: super::editor_results::SuppressionOrigin::Comment {
1689                    issue_kind: None,
1690                    reason: None,
1691                    is_file_level: false,
1692                    kind_known: true,
1693                },
1694                missing_reason: false,
1695                actions: super::editor_results::StaleSuppression::actions_for(false),
1696                effective_severity: None,
1697            }],
1698            unused_catalog_entries: vec![
1699                super::editor_results::UnusedCatalogEntryFinding::with_actions(
1700                    super::editor_results::UnusedCatalogEntry {
1701                        entry_name: "react".to_string(),
1702                        catalog_name: "default".to_string(),
1703                        path: "/pnpm-workspace.yaml".into(),
1704                        line: 16,
1705                        hardcoded_consumers: vec![],
1706                    },
1707                ),
1708            ],
1709            empty_catalog_groups: vec![
1710                super::editor_results::EmptyCatalogGroupFinding::with_actions(
1711                    super::editor_results::EmptyCatalogGroup {
1712                        catalog_name: "ui".to_string(),
1713                        path: "/pnpm-workspace.yaml".into(),
1714                        line: 17,
1715                    },
1716                ),
1717            ],
1718            unresolved_catalog_references: vec![
1719                super::editor_results::UnresolvedCatalogReferenceFinding::with_actions(
1720                    super::editor_results::UnresolvedCatalogReference {
1721                        entry_name: "vue".to_string(),
1722                        catalog_name: "default".to_string(),
1723                        path: "/pkg.json".into(),
1724                        line: 18,
1725                        available_in_catalogs: vec![],
1726                    },
1727                ),
1728            ],
1729            unused_dependency_overrides: vec![
1730                super::editor_results::UnusedDependencyOverrideFinding::with_actions(
1731                    super::editor_results::UnusedDependencyOverride {
1732                        raw_key: "react".to_string(),
1733                        target_package: "react".to_string(),
1734                        parent_package: None,
1735                        version_constraint: None,
1736                        version_range: "18".to_string(),
1737                        source: super::editor_results::DependencyOverrideSource::PnpmWorkspaceYaml,
1738                        path: "/pnpm-workspace.yaml".into(),
1739                        line: 19,
1740                        hint: None,
1741                    },
1742                ),
1743            ],
1744            misconfigured_dependency_overrides: vec![
1745                super::editor_results::MisconfiguredDependencyOverrideFinding::with_actions(
1746                    super::editor_results::MisconfiguredDependencyOverride {
1747                        raw_key: "bad>".to_string(),
1748                        target_package: None,
1749                        raw_value: String::new(),
1750                        reason:
1751                            super::editor_results::DependencyOverrideMisconfigReason::EmptyValue,
1752                        source: super::editor_results::DependencyOverrideSource::PnpmPackageJson,
1753                        path: "/pkg.json".into(),
1754                        line: 20,
1755                    },
1756                ),
1757            ],
1758            invalid_client_exports: vec![
1759                super::editor_results::InvalidClientExportFinding::with_actions(
1760                    super::editor_results::InvalidClientExport {
1761                        path: "/app/page.tsx".into(),
1762                        export_name: "metadata".to_string(),
1763                        directive: "use client".to_string(),
1764                        line: 22,
1765                        col: 0,
1766                    },
1767                ),
1768            ],
1769            mixed_client_server_barrels: vec![
1770                super::editor_results::MixedClientServerBarrelFinding::with_actions(
1771                    super::editor_results::MixedClientServerBarrel {
1772                        path: "/app/components/index.ts".into(),
1773                        client_origin: "./Button".to_string(),
1774                        server_origin: "./fetchUser".to_string(),
1775                        line: 23,
1776                        col: 0,
1777                    },
1778                ),
1779            ],
1780            misplaced_directives: vec![
1781                super::editor_results::MisplacedDirectiveFinding::with_actions(
1782                    super::editor_results::MisplacedDirective {
1783                        path: "/app/widget.tsx".into(),
1784                        directive: "use client".to_string(),
1785                        line: 24,
1786                        col: 0,
1787                    },
1788                ),
1789            ],
1790            unprovided_injects: vec![
1791                super::editor_results::UnprovidedInjectFinding::with_actions(
1792                    super::editor_results::UnprovidedInject {
1793                        path: "/Comp.vue".into(),
1794                        key_name: "ApiKey".to_string(),
1795                        framework: "vue".to_string(),
1796                        line: 25,
1797                        col: 0,
1798                    },
1799                ),
1800            ],
1801            unrendered_components: vec![
1802                super::editor_results::UnrenderedComponentFinding::with_actions(
1803                    super::editor_results::UnrenderedComponent {
1804                        path: "/Widget.vue".into(),
1805                        component_name: "Widget".to_string(),
1806                        framework: "vue".to_string(),
1807                        reachable_via: None,
1808                        line: 26,
1809                        col: 0,
1810                    },
1811                ),
1812            ],
1813            unused_component_props: vec![
1814                super::editor_results::UnusedComponentPropFinding::with_actions(
1815                    super::editor_results::UnusedComponentProp {
1816                        path: "/Widget.vue".into(),
1817                        component_name: "Widget".to_string(),
1818                        prop_name: "size".to_string(),
1819                        line: 27,
1820                        col: 0,
1821                    },
1822                ),
1823            ],
1824            absent_component_props: vec![
1825                super::editor_results::AbsentComponentPropFinding::with_actions(
1826                    super::editor_results::AbsentComponentProp {
1827                        path: "/Widget.vue".into(),
1828                        component_name: "Widget".to_string(),
1829                        framework: "vue".to_string(),
1830                        prop_name: "highlight".to_string(),
1831                        line: 27,
1832                        col: 0,
1833                        has_default: true,
1834                        inspected_call_sites: vec![super::editor_results::ComponentPropCallSite {
1835                            path: "/main.vue".into(),
1836                            line: 1,
1837                            col: 0,
1838                        }],
1839                        explanation: "Review optional prop contract".to_string(),
1840                    },
1841                ),
1842            ],
1843            unused_component_emits: vec![
1844                super::editor_results::UnusedComponentEmitFinding::with_actions(
1845                    super::editor_results::UnusedComponentEmit {
1846                        path: "/Widget.vue".into(),
1847                        component_name: "Widget".to_string(),
1848                        emit_name: "change".to_string(),
1849                        line: 28,
1850                        col: 0,
1851                    },
1852                ),
1853            ],
1854            unused_component_inputs: vec![
1855                super::editor_results::UnusedComponentInputFinding::with_actions(
1856                    super::editor_results::UnusedComponentInput {
1857                        path: "/widget.component.ts".into(),
1858                        component_name: "WidgetComponent".to_string(),
1859                        input_name: "size".to_string(),
1860                        line: 29,
1861                        col: 0,
1862                    },
1863                ),
1864            ],
1865            unused_component_outputs: vec![
1866                super::editor_results::UnusedComponentOutputFinding::with_actions(
1867                    super::editor_results::UnusedComponentOutput {
1868                        path: "/widget.component.ts".into(),
1869                        component_name: "WidgetComponent".to_string(),
1870                        output_name: "change".to_string(),
1871                        line: 30,
1872                        col: 0,
1873                    },
1874                ),
1875            ],
1876            unused_svelte_events: vec![
1877                super::editor_results::UnusedSvelteEventFinding::with_actions(
1878                    super::editor_results::UnusedSvelteEvent {
1879                        path: "/Child.svelte".into(),
1880                        component_name: "Child".to_string(),
1881                        event_name: "dead".to_string(),
1882                        line: 31,
1883                        col: 0,
1884                    },
1885                ),
1886            ],
1887            unused_effect_layers: vec![
1888                super::editor_results::UnusedEffectLayerFinding::with_actions(
1889                    super::editor_results::UnusedEffectLayer {
1890                        path: "/services.ts".into(),
1891                        name: "CacheLive".to_string(),
1892                        kind: super::editor_results::EffectLayerKind::TopLevel,
1893                        line: 33,
1894                        col: 0,
1895                        dead_consumers: vec!["OrphanLive".to_string()],
1896                    },
1897                ),
1898            ],
1899            unused_tagged_errors: vec![
1900                super::editor_results::UnusedTaggedErrorFinding::with_actions(
1901                    super::editor_results::UnusedTaggedError {
1902                        path: "/errors.ts".into(),
1903                        name: "Expired".to_string(),
1904                        tag: "Expired".to_string(),
1905                        line: 34,
1906                        col: 0,
1907                        catch_sites: Vec::new(),
1908                    },
1909                ),
1910            ],
1911            unproduced_catch_tags: vec![
1912                super::editor_results::UnproducedCatchTagFinding::with_actions(
1913                    super::editor_results::UnproducedCatchTag {
1914                        path: "/main.ts".into(),
1915                        tag: "Expired".to_string(),
1916                        line: 35,
1917                        col: 0,
1918                        candidate_classes: vec!["Expired".to_string()],
1919                    },
1920                ),
1921            ],
1922            unused_server_actions: vec![
1923                super::editor_results::UnusedServerActionFinding::with_actions(
1924                    super::editor_results::UnusedServerAction {
1925                        path: "/app/actions.ts".into(),
1926                        action_name: "createUser".to_string(),
1927                        line: 32,
1928                        col: 0,
1929                    },
1930                ),
1931            ],
1932            unused_load_data_keys: vec![
1933                super::editor_results::UnusedLoadDataKeyFinding::with_actions(
1934                    super::editor_results::UnusedLoadDataKey {
1935                        path: "/src/routes/blog/+page.server.ts".into(),
1936                        key_name: "posts".to_string(),
1937                        line: 33,
1938                        col: 0,
1939                        route_dir: None,
1940                    },
1941                ),
1942            ],
1943            unused_load_data_keys_global_abstain: true,
1944            prop_drilling_chains: vec![
1945                super::editor_results::PropDrillingChainFinding::with_actions(
1946                    super::editor_results::PropDrillingChain {
1947                        prop: "user".to_string(),
1948                        depth: 1,
1949                        hops: vec![super::editor_results::PropDrillHop {
1950                            file: "/Hop.tsx".into(),
1951                            line: 34,
1952                            component: "Hop".to_string(),
1953                        }],
1954                    },
1955                ),
1956            ],
1957            thin_wrappers: vec![super::editor_results::ThinWrapperFinding::with_actions(
1958                super::editor_results::ThinWrapper {
1959                    file: "/Wrapper.tsx".into(),
1960                    line: 35,
1961                    component: "Wrapper".to_string(),
1962                    child_component: "Child".to_string(),
1963                },
1964            )],
1965            duplicate_prop_shapes: vec![
1966                super::editor_results::DuplicatePropShapeFinding::with_actions(
1967                    super::editor_results::DuplicatePropShape {
1968                        file: "/Card.tsx".into(),
1969                        line: 36,
1970                        component: "Card".to_string(),
1971                        shape: vec!["subtitle".to_string(), "title".to_string()],
1972                        group_size: 2,
1973                        sharing_components: vec![],
1974                    },
1975                ),
1976            ],
1977            route_collisions: vec![super::editor_results::RouteCollisionFinding::with_actions(
1978                super::editor_results::RouteCollision {
1979                    path: "/app/(a)/about/page.tsx".into(),
1980                    url: "/about".to_string(),
1981                    conflicting_paths: vec!["/app/(b)/about/page.tsx".into()],
1982                    line: 1,
1983                    col: 0,
1984                },
1985            )],
1986            dynamic_segment_name_conflicts: vec![
1987                super::editor_results::DynamicSegmentNameConflictFinding::with_actions(
1988                    super::editor_results::DynamicSegmentNameConflict {
1989                        path: "/app/shop/[id]/page.tsx".into(),
1990                        position: "/shop".to_string(),
1991                        conflicting_segments: vec!["[id]".to_string(), "[slug]".to_string()],
1992                        conflicting_paths: vec!["/app/shop/[slug]/edit/page.tsx".into()],
1993                        line: 1,
1994                        col: 0,
1995                    },
1996                ),
1997            ],
1998            cascade_hidden: 1,
1999            cascade: fallow_types::results::CascadeState {
2000                suppressed_unused_files: vec!["/f.ts".into()],
2001                ..fallow_types::results::CascadeState::default()
2002            },
2003            suppression_count: 1,
2004            unused_component_props_exempted: 1,
2005            active_suppressions: vec![super::editor_results::ActiveSuppression {
2006                path: "/f.ts".into(),
2007                kind: Some("unused-export".to_string()),
2008                is_file_level: false,
2009                reason: None,
2010                comment_line: 37,
2011            }],
2012            feature_flags: vec![super::editor_results::FeatureFlag {
2013                path: "/f.ts".into(),
2014                flag_name: "ENABLE_X".to_string(),
2015                kind: super::editor_results::FlagKind::EnvironmentVariable,
2016                confidence: super::editor_results::FlagConfidence::High,
2017                line: 21,
2018                col: 0,
2019                guard_span_start: None,
2020                guard_span_end: None,
2021                sdk_name: None,
2022                guard_line_start: None,
2023                guard_line_end: None,
2024                guarded_dead_exports: vec![],
2025            }],
2026            entry_point_summary: Some(super::editor_results::EntryPointSummary {
2027                total: 0,
2028                by_source: vec![],
2029            }),
2030            security_findings: vec![super::editor_results::SecurityFinding {
2031                finding_id: String::new(),
2032                candidate: super::editor_results::SecurityCandidate::default(),
2033                taint_flow: None,
2034                attack_surface: None,
2035                kind: super::editor_results::SecurityFindingKind::ClientServerLeak,
2036                category: None,
2037                cwe: None,
2038                path: "/client.tsx".into(),
2039                line: 1,
2040                col: 0,
2041                evidence: "transitively reaches DATABASE_URL".to_string(),
2042                source_backed: false,
2043                source_read: None,
2044                severity: SecuritySeverity::Low,
2045                trace: vec![],
2046                actions: vec![],
2047                dead_code: None,
2048                reachability: None,
2049                runtime: None,
2050            }],
2051            security_unresolved_edge_files: 2,
2052            security_unresolved_callee_sites: 3,
2053            security_unresolved_callee_diagnostics: vec![
2054                super::editor_results::SecurityUnresolvedCalleeDiagnostic {
2055                    path: "/client.tsx".into(),
2056                    line: 2,
2057                    col: 0,
2058                    reason: super::editor_extract::SkippedSecurityCalleeReason::DynamicDispatch,
2059                    expression_kind:
2060                        super::editor_extract::SkippedSecurityCalleeExpressionKind::Other,
2061                },
2062            ],
2063            render_fan_in: Some(super::editor_results::RenderFanInMetric {
2064                per_component: vec![super::editor_results::RenderFanInComponent {
2065                    file: "/Button.tsx".into(),
2066                    component: "Button".to_string(),
2067                    render_sites: 6,
2068                    distinct_parents: 3,
2069                }],
2070                p95_distinct_parents: Some(3),
2071                high_pct: Some(0.0),
2072                max_distinct_parents: Some(3),
2073            }),
2074            react_component_intel: vec![super::editor_results::ReactComponentIntel {
2075                path: "/Button.tsx".into(),
2076                component_name: "Button".to_string(),
2077                anchor_line: 1,
2078                anchor_col: 0,
2079                render_sites: 6,
2080                distinct_parents: 3,
2081                prop_count: 1,
2082                hooks: super::editor_results::ReactHookSummary::default(),
2083                props: Vec::new(),
2084            }],
2085            semantic_framework_contracts: vec![fallow_types::semantic::SemanticFrameworkContract {
2086                framework: "lit".to_string(),
2087                package: "lit".to_string(),
2088                heritage_symbol: "LitElement".to_string(),
2089                heritage_names: vec!["LitElement".to_string()],
2090                relation: fallow_types::semantic::SemanticFrameworkRelation::Extends,
2091                members: vec!["render".to_string()],
2092            }],
2093        }
2094    }
2095}