Skip to main content

fallow_api/
editor.rs

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