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