Skip to main content

fallow_engine/health/
execute.rs

1//! Command-neutral health analysis execution.
2//!
3//! This module owns the health pipeline (scoring, hotspots, targets, grouping,
4//! coverage gaps, vital signs, report assembly) so that the CLI and the
5//! programmatic API can both run health analysis without the CLI orchestration
6//! layer. CLI-only concerns (config loading, telemetry sinks, the runtime
7//! coverage sidecar, ownership-resolver construction, and error rendering) are
8//! threaded in through the [`HealthSeams`] carrier and the typed result.
9
10#![allow(
11    clippy::too_many_lines,
12    clippy::too_many_arguments,
13    reason = "faithful move of the health pipeline from fallow-cli; structure preserved verbatim"
14)]
15#![allow(
16    clippy::print_stderr,
17    reason = "human stderr notes (coverage, churn, baseline) preserved verbatim from the CLI health path"
18)]
19#![allow(
20    clippy::redundant_pub_crate,
21    reason = "pub(crate) marks items the sibling health submodules consume across the private execute module"
22)]
23
24use std::process::ExitCode;
25use std::time::{Duration, Instant};
26
27use colored::Colorize;
28use fallow_config::{OutputFormat, PackageJson, ResolvedConfig, Severity};
29
30use crate::baseline::{HealthBaselineData, filter_new_health_findings, filter_new_health_targets};
31use crate::error::emit_error;
32use crate::vital_signs;
33use fallow_output::*;
34
35use super::{HealthExecutionOptions, HealthSeams, HealthSort, RuntimeCoverageSeamInput};
36
37use super::{grouping, hotspots, react_hooks, scoring, tailwind_theme, targets};
38
39use super::assembly::assemble_health_report;
40use super::hotspots::compute_hotspots;
41use super::runtime_filter::{
42    RuntimeCoverageFilterContext, apply_runtime_coverage_filters, relative_to_root,
43};
44use super::scoring::compute_file_scores;
45use super::targets::{TargetAuxData, compute_refactoring_targets};
46
47pub type HealthOptions<'a> = HealthExecutionOptions<'a>;
48
49/// Typed health analysis result generic over the CLI-owned grouping resolver.
50pub type HealthResultGeneric<R> = super::HealthAnalysisResult<R>;
51
52/// Discovery / parse inputs the CLI resolves before calling the engine.
53pub struct HealthPipelineInputs {
54    pub config: ResolvedConfig,
55    pub files: Vec<fallow_types::discover::DiscoveredFile>,
56    pub modules: Vec<fallow_types::extract::ModuleInfo>,
57    /// Pre-parse pipeline timings (config / discover / parse milliseconds).
58    pub config_ms: f64,
59    pub discover_ms: f64,
60    pub parse_ms: f64,
61    pub parse_cpu_ms: f64,
62    /// True when discover + parse were reused from the upstream check pass.
63    pub shared_parse: bool,
64    pub pre_computed_analysis: Option<crate::DeadCodeAnalysisArtifacts>,
65}
66
67/// Scope inputs the CLI resolves before calling the engine.
68///
69/// The engine no longer fetches changed files, workspace roots, the shared diff
70/// index, or the CODEOWNERS-backed grouping resolver itself: those touch CLI
71/// state (the shared-diff `OnceLock`, CODEOWNERS parsing, workspace discovery
72/// error rendering), so the CLI resolves them and threads them in here.
73pub struct HealthScopeInputs<'a, R> {
74    pub changed_files: Option<rustc_hash::FxHashSet<std::path::PathBuf>>,
75    pub diff_index: Option<&'a fallow_output::DiffIndex>,
76    pub ws_roots: Option<Vec<std::path::PathBuf>>,
77    pub group_resolver: Option<R>,
78}
79
80struct HealthPipelineTimings {
81    config: f64,
82    discover: f64,
83    parse: f64,
84    /// Summed parse CPU time across rayon workers; `0.0` when parse was reused.
85    parse_cpu: f64,
86    /// True when discover + parse were reused from the upstream check pass.
87    shared_parse: bool,
88}
89
90impl HealthPipelineTimings {
91    fn into_base_input(self, complexity_ms: f64) -> HealthTimingBaseInput {
92        HealthTimingBaseInput {
93            config_ms: self.config,
94            discover_ms: self.discover,
95            parse_ms: self.parse,
96            parse_cpu_ms: self.parse_cpu,
97            complexity_ms,
98            shared_parse: self.shared_parse,
99        }
100    }
101}
102
103struct HealthScope<'a, R> {
104    max_cyclomatic: u16,
105    max_cognitive: u16,
106    max_crap: f64,
107    enforce_crap: bool,
108    ignore_set: globset::GlobSet,
109    changed_files: Option<rustc_hash::FxHashSet<std::path::PathBuf>>,
110    diff_index: Option<&'a fallow_output::DiffIndex>,
111    ws_roots: Option<Vec<std::path::PathBuf>>,
112    group_resolver: Option<R>,
113    file_paths: rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
114}
115
116/// Validate an explicit `--churn-file` up front so a malformed import is a loud
117/// hard error (exit 2) rather than a silent hotspot skip. Runs before the
118/// pipeline, and only when churn would actually be consumed (`--hotspots` /
119/// `--targets`; `--ownership` is subsumed because the dispatch layer sets
120/// `hotspots = hotspots || ownership` before building `HealthOptions`), so an
121/// inert `--churn-file` on a non-churn run is not penalized. The gate condition
122/// mirrors `hotspots::fetch_churn_data`'s `needs_churn` exactly, keeping the
123/// validate-iff-consume invariant. Failing here (instead of inside the parallel
124/// hotspot pass) keeps combined `--format json` to a single error document. The
125/// file is re-read in `fetch_churn_data`; the duplicate read is negligible for
126/// realistic churn files and bounded by `MAX_CHURN_EVENTS`.
127fn validate_churn_file(opts: &HealthOptions<'_>) -> Result<(), ExitCode> {
128    if let Some(churn_file) = opts.churn_file
129        && (opts.hotspots || opts.targets)
130    {
131        let resolved = scoring::resolve_relative_to_root(churn_file, Some(opts.root));
132        crate::churn::analyze_churn_from_file(&resolved, opts.root)
133            .map_err(|e| emit_error(&e, 2, opts.output))?;
134    }
135    Ok(())
136}
137
138/// Validate the explicit `--churn-file` import up front (loud exit 2 on a
139/// malformed file). Exposed so the CLI wrapper can run it before the pipeline.
140pub fn validate_health_churn_file(opts: &HealthOptions<'_>) -> Result<(), ExitCode> {
141    validate_churn_file(opts)
142}
143
144/// Run the command-neutral health analysis pipeline.
145///
146/// Config loading, discovery, and parsing are the CLI's responsibility (they
147/// touch the parser cache and config telemetry); the caller passes the resolved
148/// [`HealthPipelineInputs`] plus the pre-resolved [`HealthScopeInputs`] and the
149/// [`HealthSeams`] callbacks. The returned result carries the typed health
150/// report plus the caller's grouping resolver for downstream rendering.
151///
152/// # Errors
153///
154/// Returns the CLI exit code emitted by a failing analysis or invalid input.
155pub fn execute_health_inner<'a, R: super::HealthGroupResolver>(
156    opts: &HealthOptions<'a>,
157    input: HealthPipelineInputs,
158    scope_inputs: HealthScopeInputs<'a, R>,
159    seams: &HealthSeams<'_>,
160) -> Result<HealthResultGeneric<R>, ExitCode> {
161    let start = Instant::now();
162    let HealthPipelineInputs {
163        config,
164        files,
165        modules,
166        config_ms,
167        discover_ms,
168        parse_ms,
169        parse_cpu_ms,
170        shared_parse,
171        pre_computed_analysis,
172    } = input;
173    let timings = HealthPipelineTimings {
174        config: config_ms,
175        discover: discover_ms,
176        parse: parse_ms,
177        parse_cpu: parse_cpu_ms,
178        shared_parse,
179    };
180
181    let scope = prepare_health_scope(opts, &config, &files, scope_inputs);
182
183    let HealthPreparedCore {
184        findings_data,
185        analysis_data,
186        derived_sections,
187        vital_data,
188        report_coverage_gaps,
189        enforce_coverage_gaps,
190        has_istanbul_coverage,
191        needs_file_scores,
192    } = prepare_health_core_sections(HealthCoreSectionsInput {
193        opts,
194        config: &config,
195        files: &files,
196        modules: &modules,
197        scope: &scope,
198        pre_computed_analysis,
199        seams,
200    })?;
201
202    let HealthOutputContext { build, sections } =
203        prepare_health_output_context(HealthOutputContextInput {
204            config: &config,
205            files: &files,
206            modules: &modules,
207            scope: &scope,
208            needs_file_scores,
209            report_coverage_gaps,
210            has_istanbul_coverage,
211            findings_data,
212            analysis_data,
213            derived_sections,
214            vital_data,
215            timings,
216            start: &start,
217        });
218
219    let output = build_health_output_parts(opts, &build, sections);
220
221    Ok(finalize_health_result(HealthFinalizeInput {
222        opts,
223        config,
224        files: &files,
225        scope,
226        output,
227        elapsed: start.elapsed(),
228        should_fail_on_coverage_gaps: enforce_coverage_gaps,
229    }))
230}
231
232struct HealthCoreSectionsInput<'a, R> {
233    opts: &'a HealthOptions<'a>,
234    config: &'a ResolvedConfig,
235    files: &'a [fallow_types::discover::DiscoveredFile],
236    modules: &'a [crate::extract::ModuleInfo],
237    scope: &'a HealthScope<'a, R>,
238    pre_computed_analysis: Option<crate::DeadCodeAnalysisArtifacts>,
239    seams: &'a HealthSeams<'a>,
240}
241
242struct HealthAnalysisPreludeInput<'a, R> {
243    opts: &'a HealthOptions<'a>,
244    config: &'a ResolvedConfig,
245    modules: &'a [crate::extract::ModuleInfo],
246    scope: &'a HealthScope<'a, R>,
247    pre_computed_analysis: Option<crate::DeadCodeAnalysisArtifacts>,
248    seams: &'a HealthSeams<'a>,
249}
250
251struct HealthScopedFindingsInput<'a, R> {
252    opts: &'a HealthOptions<'a>,
253    config: &'a ResolvedConfig,
254    modules: &'a [crate::extract::ModuleInfo],
255    scope: &'a HealthScope<'a, R>,
256    score_output: Option<&'a scoring::FileScoreOutput>,
257}
258
259struct HealthAnalysisPrelude {
260    analysis_data: HealthAnalysisData,
261    report_coverage_gaps: bool,
262    enforce_coverage_gaps: bool,
263    has_istanbul_coverage: bool,
264    needs_file_scores: bool,
265}
266
267struct HealthPreparedCore {
268    findings_data: HealthFindingsData,
269    analysis_data: HealthAnalysisData,
270    derived_sections: HealthDerivedSections,
271    vital_data: HealthVitalData,
272    report_coverage_gaps: bool,
273    enforce_coverage_gaps: bool,
274    has_istanbul_coverage: bool,
275    needs_file_scores: bool,
276}
277
278fn prepare_health_analysis_prelude<R>(
279    input: HealthAnalysisPreludeInput<'_, R>,
280) -> Result<HealthAnalysisPrelude, ExitCode> {
281    let HealthCoverageSettings {
282        report_coverage_gaps,
283        enforce_coverage_gaps,
284        istanbul_coverage,
285    } = prepare_health_coverage_settings(input.opts, input.config)?;
286
287    let needs_file_scores = needs_health_file_scores(
288        input.opts,
289        report_coverage_gaps,
290        enforce_coverage_gaps,
291        input.scope.enforce_crap,
292    );
293    let analysis_data = prepare_health_analysis_data(HealthAnalysisDataInput {
294        opts: input.opts,
295        config: input.config,
296        modules: input.modules,
297        file_paths: &input.scope.file_paths,
298        ignore_set: &input.scope.ignore_set,
299        changed_files: input.scope.changed_files.as_ref(),
300        ws_roots: input.scope.ws_roots.as_deref(),
301        istanbul_coverage: istanbul_coverage.as_ref(),
302        pre_computed_analysis: input.pre_computed_analysis,
303        needs_file_scores,
304        seams: input.seams,
305    })?;
306
307    Ok(HealthAnalysisPrelude {
308        analysis_data,
309        report_coverage_gaps,
310        enforce_coverage_gaps,
311        has_istanbul_coverage: istanbul_coverage.is_some(),
312        needs_file_scores,
313    })
314}
315
316fn prepare_health_scoped_findings<R>(
317    input: &HealthScopedFindingsInput<'_, R>,
318) -> Result<HealthFindingsData, ExitCode> {
319    prepare_health_findings(HealthFindingsInput {
320        opts: input.opts,
321        config: input.config,
322        modules: input.modules,
323        file_paths: &input.scope.file_paths,
324        ignore_set: &input.scope.ignore_set,
325        changed_files: input.scope.changed_files.as_ref(),
326        ws_roots: input.scope.ws_roots.as_deref(),
327        diff_index: input.scope.diff_index,
328        max_cyclomatic: input.scope.max_cyclomatic,
329        max_cognitive: input.scope.max_cognitive,
330        max_crap: input.scope.max_crap,
331        enforce_crap: input.scope.enforce_crap,
332        score_output: input.score_output,
333    })
334}
335
336fn prepare_health_core_sections<R>(
337    input: HealthCoreSectionsInput<'_, R>,
338) -> Result<HealthPreparedCore, ExitCode> {
339    let HealthCoreSectionsInput {
340        opts,
341        config,
342        files,
343        modules,
344        scope,
345        pre_computed_analysis,
346        seams,
347    } = input;
348
349    let HealthAnalysisPrelude {
350        analysis_data,
351        report_coverage_gaps,
352        enforce_coverage_gaps,
353        has_istanbul_coverage,
354        needs_file_scores,
355    } = prepare_health_analysis_prelude(HealthAnalysisPreludeInput {
356        opts,
357        config,
358        modules,
359        scope,
360        pre_computed_analysis,
361        seams,
362    })?;
363
364    let findings_data = prepare_health_scoped_findings(&HealthScopedFindingsInput {
365        opts,
366        config,
367        modules,
368        scope,
369        score_output: analysis_data.score_output.as_ref(),
370    })?;
371
372    let HealthRuntimeSections {
373        analysis_data,
374        derived_sections,
375        vital_data,
376    } = prepare_health_runtime_sections(
377        opts,
378        HealthRuntimeSectionsInput {
379            config,
380            files,
381            modules,
382            file_paths: &scope.file_paths,
383            ignore_set: &scope.ignore_set,
384            changed_files: scope.changed_files.as_ref(),
385            ws_roots: scope.ws_roots.as_deref(),
386            diff_index: scope.diff_index,
387            loaded_baseline: findings_data.loaded_baseline.as_ref(),
388            findings: &findings_data.findings,
389            analysis_data,
390            has_istanbul_coverage,
391            needs_file_scores,
392        },
393    )?;
394
395    Ok(HealthPreparedCore {
396        findings_data,
397        analysis_data,
398        derived_sections,
399        vital_data,
400        report_coverage_gaps,
401        enforce_coverage_gaps,
402        has_istanbul_coverage,
403        needs_file_scores,
404    })
405}
406
407/// The per-run scan filters shared by every CSS and markup health scanner:
408/// resolved config, the ignore globset, the optional changed-file set, and
409/// the optional workspace roots. Bundled so the scanners take one context
410/// instead of repeating the same four borrows.
411#[derive(Clone, Copy)]
412struct HealthScanCtx<'a> {
413    config: &'a ResolvedConfig,
414    ignore_set: &'a globset::GlobSet,
415    changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
416    ws_roots: Option<&'a [std::path::PathBuf]>,
417}
418
419struct HealthReportSideEffectsInput<'a> {
420    opts: &'a HealthOptions<'a>,
421    report: &'a mut fallow_output::HealthReport,
422    files: &'a [fallow_types::discover::DiscoveredFile],
423    config: &'a ResolvedConfig,
424    ignore_set: &'a globset::GlobSet,
425    changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
426    ws_roots: Option<&'a [std::path::PathBuf]>,
427}
428
429struct HealthFinalizeInput<'a, R> {
430    opts: &'a HealthOptions<'a>,
431    config: ResolvedConfig,
432    files: &'a [fallow_types::discover::DiscoveredFile],
433    scope: HealthScope<'a, R>,
434    output: HealthOutputParts,
435    elapsed: Duration,
436    should_fail_on_coverage_gaps: bool,
437}
438
439fn finalize_health_report_side_effects(input: &mut HealthReportSideEffectsInput<'_>) {
440    if input.opts.css {
441        input.report.css_analytics = compute_css_analytics_report(
442            input.files,
443            HealthScanCtx {
444                config: input.config,
445                ignore_set: input.ignore_set,
446                changed_files: input.changed_files,
447                ws_roots: input.ws_roots,
448            },
449        );
450    }
451}
452
453fn finalize_health_result<R>(input: HealthFinalizeInput<'_, R>) -> HealthResultGeneric<R> {
454    let HealthFinalizeInput {
455        opts,
456        config,
457        files,
458        scope,
459        output,
460        elapsed,
461        should_fail_on_coverage_gaps,
462    } = input;
463    let HealthOutputParts {
464        mut report,
465        grouping,
466        timings,
467        coverage_gaps_has_findings,
468    } = output;
469
470    finalize_health_report_side_effects(&mut HealthReportSideEffectsInput {
471        opts,
472        report: &mut report,
473        files,
474        config: &config,
475        ignore_set: &scope.ignore_set,
476        changed_files: scope.changed_files.as_ref(),
477        ws_roots: scope.ws_roots.as_deref(),
478    });
479
480    build_health_result(HealthResultInput {
481        config,
482        report,
483        grouping,
484        group_resolver: scope.group_resolver,
485        elapsed,
486        timings,
487        coverage_gaps_has_findings,
488        should_fail_on_coverage_gaps,
489    })
490}
491
492/// Compute structural CSS analytics, honoring the same ignore / changed-since /
493/// workspace filters as the rest of `fallow health`. Standard CSS is parsed for
494/// structural metrics; preprocessor sources are only used by candidate checks
495/// that can stay conservative without expanding Sass/Less semantics. Only
496/// stylesheets with a structurally notable rule are listed individually; the
497/// summary aggregates every analyzed stylesheet. Returns `None` when no
498/// stylesheet was analyzed.
499/// Project-wide CSS token accumulator: distinct design-token values plus the
500/// custom-property / `@keyframes` definition and reference sets, with the first
501/// stylesheet that defines/references each keyframe name so a candidate can be
502/// located. Populated per stylesheet during the discovery walk, then finalized
503/// into the summary counts and the two located keyframe candidate lists.
504#[derive(Default)]
505struct CssTokenSets {
506    colors: rustc_hash::FxHashSet<String>,
507    font_sizes: rustc_hash::FxHashSet<String>,
508    z_indexes: rustc_hash::FxHashSet<String>,
509    box_shadows: rustc_hash::FxHashSet<String>,
510    border_radii: rustc_hash::FxHashSet<String>,
511    line_heights: rustc_hash::FxHashSet<String>,
512    defined_custom_props: rustc_hash::FxHashSet<String>,
513    referenced_custom_props: rustc_hash::FxHashSet<String>,
514    defined_keyframes: rustc_hash::FxHashSet<String>,
515    referenced_keyframes: rustc_hash::FxHashSet<String>,
516    keyframes_definers: rustc_hash::FxHashMap<String, String>,
517    keyframe_referencers: rustc_hash::FxHashMap<String, String>,
518    /// Declaration-block fingerprint -> (declaration count, occurrences as
519    /// `(path, line)`), for cross-file duplicate-block detection.
520    declaration_blocks: rustc_hash::FxHashMap<u64, (u16, Vec<(String, u32)>)>,
521    /// `@property` registrations + cascade-layer declarations / populations for
522    /// cross-file unused-at-rule detection, with the first defining file per name.
523    registered_custom_props: rustc_hash::FxHashSet<String>,
524    declared_layers: rustc_hash::FxHashSet<String>,
525    populated_layers: rustc_hash::FxHashSet<String>,
526    property_registrars: rustc_hash::FxHashMap<String, String>,
527    layer_declarers: rustc_hash::FxHashMap<String, String>,
528    /// `@font-face`-declared families + referenced font families for cross-file
529    /// dead-web-font detection, with the first declaring file per family.
530    defined_font_faces: rustc_hash::FxHashSet<String>,
531    referenced_font_families: rustc_hash::FxHashSet<String>,
532    font_face_definers: rustc_hash::FxHashMap<String, String>,
533    /// Tailwind v4 `@theme` tokens (custom-property name without `--`) -> first
534    /// `(path, line)`, for the unused-theme-token candidate.
535    theme_token_definers: rustc_hash::FxHashMap<String, (String, u32)>,
536    /// Utility tokens referenced in `@apply` bodies across all CSS, so a theme
537    /// token whose utility is applied only in plain CSS is credited as used.
538    apply_tokens: rustc_hash::FxHashSet<String>,
539    /// Custom-property names (without `--`) read via `var()` inside `@theme`
540    /// interiors (lightningcss skips the unknown at-rule, so these are tracked
541    /// separately and never pollute the shared `referenced_custom_props` set
542    /// the `@property` / unreferenced-custom-property candidates diff against).
543    theme_var_reads: rustc_hash::FxHashSet<String>,
544    /// `true` when any analyzed stylesheet declares a Tailwind `@plugin`
545    /// directive: a plugin can consume theme tokens via `theme()` / `addUtilities`
546    /// invisibly to the markup / CSS / `var()` scan, so the unused-theme-token
547    /// candidate hard-abstains on plugin projects (the DI blind spot).
548    any_plugin_directive: bool,
549}
550
551impl CssTokenSets {
552    /// Group declaration-block fingerprints seen in 2+ rules into located
553    /// duplicate-block candidates, set the summary counts, and sort by estimated
554    /// savings descending (then first occurrence path).
555    fn group_duplicate_blocks(
556        &self,
557        summary: &mut fallow_output::CssAnalyticsSummary,
558    ) -> Vec<fallow_output::CssDuplicateBlock> {
559        use fallow_output::{CssBlockOccurrence, CssCandidateAction, CssDuplicateBlock};
560
561        let mut groups: Vec<CssDuplicateBlock> = self
562            .declaration_blocks
563            .values()
564            .filter(|(_, occurrences)| occurrences.len() >= 2)
565            .map(|(declaration_count, occurrences)| {
566                let occurrence_count = saturate_len(occurrences.len());
567                let estimated_savings = occurrence_count
568                    .saturating_sub(1)
569                    .saturating_mul(u32::from(*declaration_count));
570                let mut occ: Vec<CssBlockOccurrence> = occurrences
571                    .iter()
572                    .map(|(path, line)| CssBlockOccurrence {
573                        path: path.clone(),
574                        line: *line,
575                    })
576                    .collect();
577                occ.sort_by(|a, b| (&a.path, a.line).cmp(&(&b.path, b.line)));
578                CssDuplicateBlock {
579                    declaration_count: *declaration_count,
580                    occurrence_count,
581                    estimated_savings,
582                    occurrences: occ,
583                    actions: vec![CssCandidateAction::consolidate_block(occurrence_count)],
584                }
585            })
586            .collect();
587        // Highest-savings groups first; tie-break on the first occurrence path for
588        // deterministic output.
589        groups.sort_by(|a, b| {
590            b.estimated_savings
591                .cmp(&a.estimated_savings)
592                .then_with(|| occurrence_sort_key(a).cmp(&occurrence_sort_key(b)))
593        });
594        summary.duplicate_declaration_blocks = saturate_len(groups.len());
595        summary.duplicate_declarations_total = groups
596            .iter()
597            .fold(0u32, |acc, g| acc.saturating_add(g.estimated_savings));
598        groups
599    }
600
601    /// Fold one stylesheet's analytics into the project-wide token sets,
602    /// recording the first-defining file (`rel`) per located name.
603    fn record(&mut self, analytics: &fallow_types::extract::CssAnalytics, rel: &str) {
604        self.colors.extend(analytics.colors.iter().cloned());
605        self.font_sizes.extend(analytics.font_sizes.iter().cloned());
606        self.z_indexes.extend(analytics.z_indexes.iter().cloned());
607        self.box_shadows
608            .extend(analytics.box_shadows.iter().cloned());
609        self.border_radii
610            .extend(analytics.border_radii.iter().cloned());
611        self.line_heights
612            .extend(analytics.line_heights.iter().cloned());
613        self.defined_custom_props
614            .extend(analytics.defined_custom_properties.iter().cloned());
615        self.referenced_custom_props
616            .extend(analytics.referenced_custom_properties.iter().cloned());
617        for keyframes in &analytics.referenced_keyframes {
618            self.referenced_keyframes.insert(keyframes.clone());
619            self.keyframe_referencers
620                .entry(keyframes.clone())
621                .or_insert_with(|| rel.to_owned());
622        }
623        for keyframes in &analytics.defined_keyframes {
624            self.defined_keyframes.insert(keyframes.clone());
625            self.keyframes_definers
626                .entry(keyframes.clone())
627                .or_insert_with(|| rel.to_owned());
628        }
629        for block in &analytics.declaration_blocks {
630            self.declaration_blocks
631                .entry(block.fingerprint)
632                .or_insert_with(|| (block.declaration_count, Vec::new()))
633                .1
634                .push((rel.to_owned(), block.line));
635        }
636        for name in &analytics.registered_custom_properties {
637            self.registered_custom_props.insert(name.clone());
638            self.property_registrars
639                .entry(name.clone())
640                .or_insert_with(|| rel.to_owned());
641        }
642        for family in &analytics.referenced_font_families {
643            self.referenced_font_families.insert(family.clone());
644        }
645        for family in &analytics.defined_font_faces {
646            self.defined_font_faces.insert(family.clone());
647            self.font_face_definers
648                .entry(family.clone())
649                .or_insert_with(|| rel.to_owned());
650        }
651        for name in &analytics.populated_layers {
652            self.populated_layers.insert(name.clone());
653        }
654        for name in &analytics.declared_layers {
655            self.declared_layers.insert(name.clone());
656            self.layer_declarers
657                .entry(name.clone())
658                .or_insert_with(|| rel.to_owned());
659        }
660    }
661
662    /// Fold one stylesheet's Tailwind v4 `@theme` tokens, `@apply` body tokens,
663    /// and `@theme`-interior `var()` reads into the project-wide sets (the inputs
664    /// to the unused-theme-token candidate). `scan_theme_blocks` /
665    /// `extract_apply_tokens` fast-path out on sources with no `@theme` / `@apply`,
666    /// so this is near-free for non-Tailwind stylesheets.
667    fn record_theme(&mut self, source: &str, rel: &str) {
668        let scan = crate::extract::scan_theme_blocks(source);
669        for token in scan.tokens {
670            self.theme_token_definers
671                .entry(token.name)
672                .or_insert_with(|| (rel.to_owned(), token.line));
673        }
674        self.theme_var_reads.extend(scan.theme_var_reads);
675        self.apply_tokens
676            .extend(crate::extract::extract_apply_tokens(source));
677        if source.contains("@plugin") {
678            self.any_plugin_directive = true;
679        }
680    }
681
682    /// Group unused CSS at-rule entities: `@property` registrations never read
683    /// via `var()`, and cascade layers declared but never populated. Sets the
684    /// summary counts and returns the located list sorted by (kind, path, name).
685    fn group_unused_at_rules(
686        &self,
687        summary: &mut fallow_output::CssAnalyticsSummary,
688    ) -> Vec<fallow_output::UnusedAtRule> {
689        use fallow_output::{CssCandidateAction, UnusedAtRule, UnusedAtRuleKind};
690
691        let mut out: Vec<UnusedAtRule> = Vec::new();
692        for name in self
693            .registered_custom_props
694            .difference(&self.referenced_custom_props)
695        {
696            out.push(UnusedAtRule {
697                kind: UnusedAtRuleKind::PropertyRegistration,
698                name: name.clone(),
699                path: self
700                    .property_registrars
701                    .get(name)
702                    .cloned()
703                    .unwrap_or_default(),
704                actions: vec![CssCandidateAction::verify_unused_at_rule(
705                    UnusedAtRuleKind::PropertyRegistration,
706                    name,
707                )],
708            });
709        }
710        summary.unused_property_registrations = saturate_len(out.len());
711        let property_count = out.len();
712        for name in self.declared_layers.difference(&self.populated_layers) {
713            out.push(UnusedAtRule {
714                kind: UnusedAtRuleKind::Layer,
715                name: name.clone(),
716                path: self.layer_declarers.get(name).cloned().unwrap_or_default(),
717                actions: vec![CssCandidateAction::verify_unused_at_rule(
718                    UnusedAtRuleKind::Layer,
719                    name,
720                )],
721            });
722        }
723        summary.unused_layers = saturate_len(out.len() - property_count);
724        out.sort_by(|a, b| (a.kind as u8, &a.path, &a.name).cmp(&(b.kind as u8, &b.path, &b.name)));
725        out
726    }
727
728    /// Fill the summary token counts and return the two located keyframe
729    /// candidate lists: defined-but-unused (`unreferenced`) and used-but-
730    /// undefined (`undefined`).
731    fn finalize(
732        &self,
733        summary: &mut fallow_output::CssAnalyticsSummary,
734    ) -> (
735        Vec<fallow_output::UnreferencedKeyframes>,
736        Vec<fallow_output::UndefinedKeyframes>,
737    ) {
738        use fallow_output::{CssCandidateAction, UndefinedKeyframes, UnreferencedKeyframes};
739
740        summary.unique_colors = saturate_len(self.colors.len());
741        summary.unique_font_sizes = saturate_len(self.font_sizes.len());
742        summary.unique_z_indexes = saturate_len(self.z_indexes.len());
743        summary.unique_box_shadows = saturate_len(self.box_shadows.len());
744        summary.unique_border_radii = saturate_len(self.border_radii.len());
745        summary.unique_line_heights = saturate_len(self.line_heights.len());
746        summary.custom_properties_defined = saturate_len(self.defined_custom_props.len());
747        summary.custom_properties_unreferenced = saturate_len(
748            self.defined_custom_props
749                .difference(&self.referenced_custom_props)
750                .count(),
751        );
752        // Count-only (per panel review): a var() referenced but defined in no
753        // stylesheet is dominated by JS-set design tokens, so locating these
754        // would be net-noise. The count is an architecture signal.
755        summary.custom_properties_undefined = saturate_len(
756            self.referenced_custom_props
757                .difference(&self.defined_custom_props)
758                .count(),
759        );
760        summary.keyframes_defined = saturate_len(self.defined_keyframes.len());
761        summary.keyframes_unreferenced = saturate_len(
762            self.defined_keyframes
763                .difference(&self.referenced_keyframes)
764                .count(),
765        );
766        summary.keyframes_undefined = saturate_len(
767            self.referenced_keyframes
768                .difference(&self.defined_keyframes)
769                .count(),
770        );
771
772        // @keyframes are low-cardinality, so BOTH directions are located (not
773        // just counted): defined-but-unused, and used-but-defined-nowhere.
774        let unreferenced_keyframes = locate_keyframe_diff(
775            &self.defined_keyframes,
776            &self.referenced_keyframes,
777            &self.keyframes_definers,
778        )
779        .into_iter()
780        .map(|(name, path)| UnreferencedKeyframes {
781            actions: vec![CssCandidateAction::verify_keyframe(&name)],
782            name,
783            path,
784        })
785        .collect();
786        let undefined_keyframes = locate_keyframe_diff(
787            &self.referenced_keyframes,
788            &self.defined_keyframes,
789            &self.keyframe_referencers,
790        )
791        .into_iter()
792        .map(|(name, path)| UndefinedKeyframes {
793            actions: vec![CssCandidateAction::verify_undefined_keyframe(&name)],
794            name,
795            path,
796        })
797        .collect();
798        (unreferenced_keyframes, undefined_keyframes)
799    }
800
801    /// `@font-face`-declared families referenced by no `font-family` anywhere in
802    /// the project: a dead web-font payload. Located at the declaring stylesheet,
803    /// set the summary count.
804    fn unused_font_faces(
805        &self,
806        summary: &mut fallow_output::CssAnalyticsSummary,
807    ) -> Vec<fallow_output::UnusedFontFace> {
808        use fallow_output::{CssCandidateAction, UnusedFontFace};
809        // CSS font-family names are case-insensitive (CSS Fonts Level 4 4.2.1),
810        // unlike `@keyframes` custom-ident names (case-sensitive, via
811        // `locate_keyframe_diff`), so match case-insensitively while keeping the
812        // declared casing for both display and the verify command.
813        let referenced_lower: rustc_hash::FxHashSet<String> = self
814            .referenced_font_families
815            .iter()
816            .map(|family| family.to_ascii_lowercase())
817            .collect();
818        let mut out: Vec<UnusedFontFace> = self
819            .defined_font_faces
820            .iter()
821            .filter(|family| !referenced_lower.contains(&family.to_ascii_lowercase()))
822            .map(|family| UnusedFontFace {
823                actions: vec![CssCandidateAction::verify_unused_font_face(family)],
824                path: self
825                    .font_face_definers
826                    .get(family)
827                    .cloned()
828                    .unwrap_or_default(),
829                family: family.clone(),
830            })
831            .collect();
832        out.sort_by(|a, b| (&a.path, &a.family).cmp(&(&b.path, &b.family)));
833        summary.unused_font_faces = saturate_len(out.len());
834        out
835    }
836
837    /// Group the distinct `font-size` values by length unit (`px`/`rem`/`em`/`%`/
838    /// `pt`/other), set the `font_size_units_used` count, and, when the project
839    /// mixes two or more units across enough distinct sizes, return a
840    /// consistency candidate (mixing `px` and `rem` for type works against
841    /// user-zoom accessibility). Advisory only, never gated.
842    fn font_size_unit_mix(
843        &self,
844        summary: &mut fallow_output::CssAnalyticsSummary,
845    ) -> Option<fallow_output::CssNotationConsistency> {
846        use fallow_output::{CssCandidateAction, CssNotationConsistency, CssNotationCount};
847
848        let mut counts: rustc_hash::FxHashMap<&'static str, u32> = rustc_hash::FxHashMap::default();
849        for value in &self.font_sizes {
850            if let Some(unit) = classify_font_size_unit(value) {
851                *counts.entry(unit).or_insert(0) += 1;
852            }
853        }
854        summary.font_size_units_used = saturate_len(counts.len());
855
856        // Conservative floor: at least two distinct units AND enough classified
857        // sizes that the project plainly has a type scale (so a tiny stylesheet
858        // with one px and one rem does not trip it). Smoke-tunable.
859        let total: u32 = counts.values().copied().sum();
860        if counts.len() < 2 || total < MIN_FONT_SIZE_UNIT_MIX {
861            return None;
862        }
863        let mut notations: Vec<CssNotationCount> = counts
864            .into_iter()
865            .map(|(notation, count)| CssNotationCount {
866                notation: notation.to_owned(),
867                count,
868            })
869            .collect();
870        // Dominant unit first; tie-break on the unit name for deterministic output.
871        notations.sort_by(|a, b| {
872            b.count
873                .cmp(&a.count)
874                .then_with(|| a.notation.cmp(&b.notation))
875        });
876        // Safe: the floor guard above guarantees at least two notations.
877        let dominant = notations[0].notation.clone();
878        Some(CssNotationConsistency {
879            actions: vec![CssCandidateAction::standardize_notation(
880                "Font sizes",
881                &dominant,
882            )],
883            axis: "Font sizes".to_owned(),
884            notations,
885        })
886    }
887}
888
889/// Fewest distinct unit-classified `font-size` values before a unit-mix candidate
890/// is worth surfacing. Below this the project does not yet have a type scale, so
891/// a px/rem split is noise rather than an inconsistency.
892const MIN_FONT_SIZE_UNIT_MIX: u32 = 6;
893
894/// Classify a `font-size` value's length unit for the unit-consistency
895/// candidate. Returns `None` for function values (`clamp()` / `calc()` /
896/// `min()` / `max()` / `var()`) and bare keywords (`medium`, `larger`,
897/// `inherit`), which carry no single comparable unit. Unit names are lowercased;
898/// recognized type units map to a stable label, anything else to `"other"`.
899fn classify_font_size_unit(value: &str) -> Option<&'static str> {
900    let v = value.trim();
901    if v.is_empty() || v.contains('(') {
902        return None;
903    }
904    if let Some(stripped) = v.strip_suffix('%') {
905        // A bare `%` font-size is `<number>%`; reject anything else (defensive).
906        return stripped
907            .chars()
908            .all(|c| c.is_ascii_digit() || c == '.')
909            .then_some("%");
910    }
911    let unit_start = v.find(|c: char| c.is_ascii_alphabetic())?;
912    let (number, unit) = v.split_at(unit_start);
913    // A dimension is `<number><unit>`; a leading non-numeric prefix means a
914    // keyword (e.g. `medium`), which has no unit.
915    if number.is_empty()
916        || !number
917            .chars()
918            .all(|c| c.is_ascii_digit() || c == '.' || c == '-' || c == '+')
919    {
920        return None;
921    }
922    match unit.to_ascii_lowercase().as_str() {
923        "px" => Some("px"),
924        "rem" => Some("rem"),
925        "em" => Some("em"),
926        "pt" => Some("pt"),
927        _ => Some("other"),
928    }
929}
930
931/// Build the sorted `(name, path)` set difference `present - absent`, locating
932/// each surviving name via `locator` (empty path when absent). Sorted by
933/// `(path, name)` for deterministic output.
934fn locate_keyframe_diff(
935    present: &rustc_hash::FxHashSet<String>,
936    absent: &rustc_hash::FxHashSet<String>,
937    locator: &rustc_hash::FxHashMap<String, String>,
938) -> Vec<(String, String)> {
939    let mut out: Vec<(String, String)> = present
940        .difference(absent)
941        .map(|name| (name.clone(), locator.get(name).cloned().unwrap_or_default()))
942        .collect();
943    out.sort_by(|a, b| (&a.1, &a.0).cmp(&(&b.1, &b.0)));
944    out
945}
946
947/// Saturating `usize -> u32` for token counts.
948fn saturate_len(len: usize) -> u32 {
949    u32::try_from(len).unwrap_or(u32::MAX)
950}
951
952/// `(first path, first line)` sort key for a duplicate block; occurrences are
953/// pre-sorted, so the first is the lexicographic minimum.
954fn occurrence_sort_key(block: &fallow_output::CssDuplicateBlock) -> (&str, u32) {
955    block
956        .occurrences
957        .first()
958        .map_or(("", 0), |occ| (occ.path.as_str(), occ.line))
959}
960
961/// Returns `true` when the project's root `package.json` declares a Tailwind
962/// dependency (`tailwindcss` or any `@tailwindcss/*`), used to gate the
963/// arbitrary-value markup scan: the `prefix-[value]` token shape is Tailwind-
964/// specific in practice but not formally exclusive.
965fn project_uses_tailwind(root: &std::path::Path) -> bool {
966    let Ok(text) = std::fs::read_to_string(root.join("package.json")) else {
967        return false;
968    };
969    let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) else {
970        return false;
971    };
972    ["dependencies", "devDependencies", "peerDependencies"]
973        .iter()
974        .any(|key| {
975            json.get(key)
976                .and_then(serde_json::Value::as_object)
977                .is_some_and(|deps| {
978                    deps.keys()
979                        .any(|k| k == "tailwindcss" || k.starts_with("@tailwindcss/"))
980                })
981        })
982}
983
984/// Scan the project's markup (`.jsx` / `.tsx` / `.html` / `.astro` / `.vue` /
985/// `.svelte`) for Tailwind arbitrary-value utility tokens, honoring the same
986/// ignore / changed / workspace filters as the CSS scan. Aggregates by token
987/// (total count + first location), sets the summary counts, and returns the
988/// located list sorted by use count descending.
989/// One eligible markup file (`jsx`/`tsx`/`html`/`astro`/`vue`/`svelte`) for a
990/// class-token scan: the forward-slash relative path plus source, or `None` when
991/// the file is filtered out (extension, ignore set, changed-files, workspace
992/// scope) or unreadable.
993fn read_markup_scan_source(
994    file: &fallow_types::discover::DiscoveredFile,
995    ctx: HealthScanCtx<'_>,
996) -> Option<(String, String)> {
997    let HealthScanCtx {
998        config,
999        ignore_set,
1000        changed_files,
1001        ws_roots,
1002    } = ctx;
1003
1004    let path = &file.path;
1005    let extension = path.extension().and_then(|ext| ext.to_str());
1006    if !matches!(
1007        extension,
1008        Some("jsx" | "tsx" | "html" | "astro" | "vue" | "svelte")
1009    ) {
1010        return None;
1011    }
1012    let relative = path.strip_prefix(&config.root).unwrap_or(path);
1013    if ignore_set.is_match(relative) {
1014        return None;
1015    }
1016    if let Some(changed) = changed_files
1017        && !changed.contains(path)
1018    {
1019        return None;
1020    }
1021    if let Some(roots) = ws_roots
1022        && !roots.iter().any(|root| path.starts_with(root))
1023    {
1024        return None;
1025    }
1026    let source = std::fs::read_to_string(path).ok()?;
1027    let rel = relative.to_string_lossy().replace('\\', "/");
1028    Some((rel, source))
1029}
1030
1031fn scan_markup_tailwind_arbitrary_values(
1032    files: &[fallow_types::discover::DiscoveredFile],
1033    ctx: HealthScanCtx<'_>,
1034    summary: &mut fallow_output::CssAnalyticsSummary,
1035) -> Vec<fallow_output::TailwindArbitraryValue> {
1036    let HealthScanCtx { config, .. } = ctx;
1037
1038    use fallow_output::TailwindArbitraryValue;
1039
1040    if !project_uses_tailwind(&config.root) {
1041        return Vec::new();
1042    }
1043    // token -> (total count, first path, first line). First-seen wins for the
1044    // location; files are path-sorted, so the first occurrence is deterministic.
1045    let mut agg: rustc_hash::FxHashMap<String, (u32, String, u32)> =
1046        rustc_hash::FxHashMap::default();
1047    let mut total_uses: u32 = 0;
1048    for file in files {
1049        let Some((rel, source)) = read_markup_scan_source(file, ctx) else {
1050            continue;
1051        };
1052        for arb in crate::extract::scan_tailwind_arbitrary_values(&source) {
1053            total_uses = total_uses.saturating_add(1);
1054            let entry = agg
1055                .entry(arb.value)
1056                .or_insert_with(|| (0, rel.clone(), arb.line));
1057            entry.0 = entry.0.saturating_add(1);
1058        }
1059    }
1060
1061    summary.tailwind_arbitrary_values = saturate_len(agg.len());
1062    summary.tailwind_arbitrary_value_uses = total_uses;
1063    let mut out: Vec<TailwindArbitraryValue> = agg
1064        .into_iter()
1065        .map(|(value, (count, path, line))| TailwindArbitraryValue {
1066            actions: vec![fallow_output::CssCandidateAction::replace_arbitrary_value(
1067                &value,
1068            )],
1069            value,
1070            count,
1071            path,
1072            line,
1073        })
1074        .collect();
1075    out.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.value.cmp(&b.value)));
1076    out
1077}
1078
1079/// True for a byte that can appear inside a Tailwind class token (used to anchor
1080/// the `animate-` prefix at a token boundary so `xanimate-` does not match).
1081fn is_tailwind_class_byte(b: u8) -> bool {
1082    b.is_ascii_alphanumeric() || b == b'-' || b == b'_'
1083}
1084
1085/// Extract `@keyframes` names applied via Tailwind from one source string: the
1086/// custom-ident after `animate-[<name>_...]` (arbitrary value, up to the first
1087/// `_`/`]`) and after a bare `animate-<name>` utility. The `animate-` prefix must
1088/// sit at a token boundary. Names are collected raw; the caller filters them to
1089/// actually-defined keyframes.
1090fn collect_animate_keyframe_names(source: &str, out: &mut rustc_hash::FxHashSet<String>) {
1091    let bytes = source.as_bytes();
1092    const PREFIX: &str = "animate-";
1093    let mut search = 0;
1094    while let Some(rel) = source[search..].find(PREFIX) {
1095        let start = search + rel;
1096        search = start + PREFIX.len();
1097        // The prefix must start at a token boundary (`hover:animate-x` is fine,
1098        // `myanimate-x` is not).
1099        if start > 0 && is_tailwind_class_byte(bytes[start - 1]) {
1100            continue;
1101        }
1102        let after = start + PREFIX.len();
1103        if after >= bytes.len() {
1104            continue;
1105        }
1106        if bytes[after] == b'[' {
1107            // Arbitrary value: `animate-[badge-pop_0.5s_...]` -> `badge-pop`.
1108            let name_start = after + 1;
1109            let mut j = name_start;
1110            while j < bytes.len() {
1111                let c = bytes[j];
1112                if c == b'-' || c.is_ascii_alphanumeric() {
1113                    j += 1;
1114                } else {
1115                    break;
1116                }
1117            }
1118            if j > name_start {
1119                out.insert(source[name_start..j].to_owned());
1120            }
1121        } else {
1122            // Named utility: `animate-bar-fill` -> `bar-fill`.
1123            let mut j = after;
1124            while j < bytes.len() {
1125                let c = bytes[j];
1126                if c == b'-' || c.is_ascii_lowercase() || c.is_ascii_digit() {
1127                    j += 1;
1128                } else {
1129                    break;
1130                }
1131            }
1132            let name = source[after..j].trim_end_matches('-');
1133            if !name.is_empty() {
1134                out.insert(name.to_owned());
1135            }
1136        }
1137    }
1138}
1139
1140/// Collect `@keyframes` names applied via Tailwind markup utilities
1141/// (`animate-[name_...]` / `animate-name`) across the project's markup and JS,
1142/// so a keyframe used only that way (never via a CSS `animation:` declaration)
1143/// is not wrongly flagged `unreferenced`. Not gated on the Tailwind dependency:
1144/// the `animate-[...]` / `animate-<name>` shapes are distinctive, the caller
1145/// filters the result to actually-defined keyframes, and a project can apply
1146/// Tailwind utilities without declaring the npm dep at the scanned root
1147/// (CDN / PostCSS / monorepo subpackage).
1148fn collect_markup_keyframe_references(
1149    files: &[fallow_types::discover::DiscoveredFile],
1150    config: &ResolvedConfig,
1151    ignore_set: &globset::GlobSet,
1152) -> rustc_hash::FxHashSet<String> {
1153    let mut out: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
1154    for file in files {
1155        let path = &file.path;
1156        let extension = path.extension().and_then(|ext| ext.to_str());
1157        if !matches!(
1158            extension,
1159            Some("jsx" | "tsx" | "html" | "astro" | "vue" | "svelte" | "js" | "ts" | "mjs" | "cjs")
1160        ) {
1161            continue;
1162        }
1163        let relative = path.strip_prefix(&config.root).unwrap_or(path);
1164        if ignore_set.is_match(relative) {
1165            continue;
1166        }
1167        if let Ok(source) = std::fs::read_to_string(path) {
1168            collect_animate_keyframe_names(&source, &mut out);
1169            // Also a keyframe named in a JS inline-style `animation:` /
1170            // `animationName:` string (`animation: 'progress-indeterminate 1.5s'`)
1171            // appears as a dashed token in a quoted string; the caller filters
1172            // these to actually-defined keyframes, so an unrelated dashed token
1173            // can never manufacture a reference. `require_dash: false` so a
1174            // single-word keyframe name (`spin`, `jsanim`) is credited too.
1175            collect_quoted_class_tokens(&source, &mut out, false);
1176        }
1177    }
1178    out
1179}
1180
1181/// Shortest authored CSS class that can be a credible typo target. Below this a
1182/// one-edit near miss is too likely to be a coincidental collision between two
1183/// short real words (`catch` vs `match`, `list` vs `last`) rather than a typo.
1184/// Real component-class typos are compound / hyphenated and comfortably longer.
1185/// (Real-world smoke on Svelte: `catch` vs `match` in test fixtures.)
1186const MIN_DEFINED_CLASS_LEN: usize = 6;
1187/// Shortest markup token worth typo-checking, for the same reason. One below the
1188/// defined floor, since a one-edit pair differs in length by at most one.
1189const MIN_TOKEN_LEN: usize = 5;
1190
1191/// Count plain-CSS vs preprocessor (`.scss`/`.sass`/`.less`) stylesheet files in
1192/// the project (ignore-filtered). Used to abstain from class-typo detection when
1193/// preprocessors dominate, because the parser cannot expand their loops/mixins,
1194/// so the defined-class set is unreliable.
1195fn count_stylesheet_kinds(
1196    files: &[fallow_types::discover::DiscoveredFile],
1197    config: &ResolvedConfig,
1198    ignore_set: &globset::GlobSet,
1199) -> (usize, usize) {
1200    let mut css = 0usize;
1201    let mut preprocessor = 0usize;
1202    for file in files {
1203        let path = &file.path;
1204        let kind = match path.extension().and_then(|ext| ext.to_str()) {
1205            Some("css") => &mut css,
1206            Some("scss" | "sass" | "less") => &mut preprocessor,
1207            _ => continue,
1208        };
1209        let relative = path.strip_prefix(&config.root).unwrap_or(path);
1210        if ignore_set.is_match(relative) {
1211            continue;
1212        }
1213        *kind += 1;
1214    }
1215    (css, preprocessor)
1216}
1217
1218/// Collect every authored CSS class name defined anywhere in the project (plain
1219/// and module `.css`/`.scss`, plus Astro/SFC `<style>` blocks of any scoping). The set
1220/// is the typo-suggestion target for [`scan_unresolved_class_references`], so it
1221/// is NOT narrowed by `changed_files` / `ws_roots`: a class defined in an
1222/// unchanged file must still count as defined, or a markup token referencing it
1223/// would false-positive as unresolved. Only the ignore filter applies.
1224fn collect_defined_css_classes(
1225    files: &[fallow_types::discover::DiscoveredFile],
1226    config: &ResolvedConfig,
1227    ignore_set: &globset::GlobSet,
1228) -> rustc_hash::FxHashSet<String> {
1229    use fallow_types::extract::ExportName;
1230    let mut defined: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
1231    for file in files {
1232        let path = &file.path;
1233        let extension = path.extension().and_then(|ext| ext.to_str());
1234        let is_preprocessor = matches!(extension, Some("scss" | "sass" | "less"));
1235        let is_css = extension == Some("css") || is_preprocessor;
1236        let has_style_blocks = matches!(extension, Some("astro" | "vue" | "svelte"));
1237        if !is_css && !has_style_blocks {
1238            continue;
1239        }
1240        let relative = path.strip_prefix(&config.root).unwrap_or(path);
1241        if ignore_set.is_match(relative) {
1242            continue;
1243        }
1244        let Ok(source) = std::fs::read_to_string(path) else {
1245            continue;
1246        };
1247        if has_style_blocks {
1248            for style in crate::extract::extract_sfc_styles(&source) {
1249                let is_style_scss = style
1250                    .lang
1251                    .as_deref()
1252                    .is_some_and(|lang| matches!(lang, "scss" | "sass"));
1253                for export in crate::extract::extract_css_module_exports(&style.body, is_style_scss)
1254                {
1255                    if let ExportName::Named(name) = export.name {
1256                        defined.insert(name);
1257                    }
1258                }
1259            }
1260            continue;
1261        }
1262        for export in crate::extract::extract_css_module_exports(&source, is_preprocessor) {
1263            if let ExportName::Named(name) = export.name {
1264                defined.insert(name);
1265            }
1266        }
1267    }
1268    defined
1269}
1270
1271/// Find the best one-edit typo suggestion for a markup token among the defined
1272/// classes, using a length-bucketed index so only classes of length `len-1`,
1273/// `len`, `len+1` are compared. Returns the lexicographically smallest defined
1274/// class at edit distance one (deterministic), or `None`.
1275fn best_class_suggestion<'a>(
1276    token: &str,
1277    by_len: &'a rustc_hash::FxHashMap<usize, Vec<&'a str>>,
1278) -> Option<&'a str> {
1279    let len = token.len();
1280    let mut best: Option<&str> = None;
1281    for candidate_len in [len.wrapping_sub(1), len, len + 1] {
1282        let Some(bucket) = by_len.get(&candidate_len) else {
1283            continue;
1284        };
1285        for &defined in bucket {
1286            if defined.len() < MIN_DEFINED_CLASS_LEN {
1287                continue;
1288            }
1289            if crate::extract::is_typo_edit(token, defined)
1290                && best.is_none_or(|current| defined < current)
1291            {
1292                best = Some(defined);
1293            }
1294        }
1295    }
1296    best
1297}
1298
1299/// True when a markup class token is Tailwind-flavored (a variant prefix `:`,
1300/// an opacity `/`, or an arbitrary-value bracket), so it is not an authored CSS
1301/// class and never a typo candidate.
1302fn is_tailwind_shaped(token: &str) -> bool {
1303    token.contains([':', '/', '[', ']'])
1304}
1305
1306/// Length-bucketed index over the typo-target classes for O(1)-ish near-miss.
1307/// Drops names ending in `-` / `_`: those are SCSS interpolation artifacts
1308/// (`.display-#{$i}` parsed by lightningcss as a partial `display-`), never a
1309/// real typo target.
1310fn build_typo_target_index(
1311    defined: &rustc_hash::FxHashSet<String>,
1312) -> rustc_hash::FxHashMap<usize, Vec<&str>> {
1313    let mut by_len: rustc_hash::FxHashMap<usize, Vec<&str>> = rustc_hash::FxHashMap::default();
1314    for class in defined {
1315        if class.len() >= MIN_DEFINED_CLASS_LEN && !class.ends_with('-') && !class.ends_with('_') {
1316            by_len.entry(class.len()).or_default().push(class.as_str());
1317        }
1318    }
1319    by_len
1320}
1321
1322/// Collect the likely-typo class references in one markup source into `out`,
1323/// deduping by `(rel, line, value)` via `seen`.
1324fn collect_unresolved_class_refs_in_file<'a>(
1325    source: &str,
1326    rel: &str,
1327    defined: &rustc_hash::FxHashSet<String>,
1328    by_len: &'a rustc_hash::FxHashMap<usize, Vec<&'a str>>,
1329    seen: &mut rustc_hash::FxHashSet<(String, u32, String)>,
1330    out: &mut Vec<fallow_output::UnresolvedClassReference>,
1331) {
1332    use fallow_output::{CssCandidateAction, UnresolvedClassReference};
1333    for token in crate::extract::scan_markup_class_tokens(source).static_tokens {
1334        if token.value.len() < MIN_TOKEN_LEN
1335            || is_tailwind_shaped(&token.value)
1336            || defined.contains(&token.value)
1337        {
1338            continue;
1339        }
1340        let Some(suggestion) = best_class_suggestion(&token.value, by_len) else {
1341            continue;
1342        };
1343        let key = (rel.to_owned(), token.line, token.value.clone());
1344        if !seen.insert(key) {
1345            continue;
1346        }
1347        out.push(UnresolvedClassReference {
1348            actions: vec![CssCandidateAction::verify_unresolved_class(
1349                &token.value,
1350                suggestion,
1351            )],
1352            class: token.value,
1353            suggestion: suggestion.to_owned(),
1354            path: rel.to_owned(),
1355            line: token.line,
1356        });
1357    }
1358}
1359
1360/// Scan markup for static `class` / `className` tokens that match no defined CSS
1361/// class but are one edit from a defined class (a likely typo / stale rename).
1362/// The defined set is the full project; markup honors the ignore / changed /
1363/// workspace filters (a typo is local). Near-zero false-positive by the near-miss
1364/// restriction: Tailwind utilities and third-party classes are not one edit from
1365/// an authored class. Candidates, never gated.
1366fn scan_unresolved_class_references(
1367    files: &[fallow_types::discover::DiscoveredFile],
1368    ctx: HealthScanCtx<'_>,
1369    summary: &mut fallow_output::CssAnalyticsSummary,
1370) -> Vec<fallow_output::UnresolvedClassReference> {
1371    let HealthScanCtx {
1372        config, ignore_set, ..
1373    } = ctx;
1374
1375    use fallow_output::UnresolvedClassReference;
1376
1377    // Abstain on preprocessor-dominant projects. lightningcss parses `.scss` /
1378    // `.sass` / `.less` source textually but cannot expand loops / mixins, so a
1379    // generated class (`.bg-#{$color}`, `.col-#{$i}`) is invisible to the defined
1380    // set. On a SCSS framework like Bootstrap that makes a real, used class
1381    // (`bg-white`) look unresolved and false-positive as a typo of a parsed
1382    // sibling. When preprocessor stylesheets outnumber plain CSS, the defined set
1383    // is too incomplete to trust, so emit nothing (real-world smoke: Bootstrap).
1384    let (css_files, preprocessor_files) = count_stylesheet_kinds(files, config, ignore_set);
1385    if preprocessor_files > css_files {
1386        return Vec::new();
1387    }
1388
1389    let defined = collect_defined_css_classes(files, config, ignore_set);
1390    if defined.is_empty() {
1391        return Vec::new();
1392    }
1393    let by_len = build_typo_target_index(&defined);
1394
1395    let mut out: Vec<UnresolvedClassReference> = Vec::new();
1396    let mut seen: rustc_hash::FxHashSet<(String, u32, String)> = rustc_hash::FxHashSet::default();
1397    for file in files {
1398        let Some((rel, source)) = read_markup_scan_source(file, ctx) else {
1399            continue;
1400        };
1401        collect_unresolved_class_refs_in_file(
1402            &source, &rel, &defined, &by_len, &mut seen, &mut out,
1403        );
1404    }
1405
1406    out.sort_by(|a, b| {
1407        a.path
1408            .cmp(&b.path)
1409            .then_with(|| a.line.cmp(&b.line))
1410            .then_with(|| a.class.cmp(&b.class))
1411    });
1412    summary.unresolved_class_references = saturate_len(out.len());
1413    out
1414}
1415
1416/// Blank every `@font-face { ... }` block in a (lowercased) source so a declared
1417/// family's own `font-family:` inside its definition does not self-credit when
1418/// the source is scanned for OTHER references to that family. The `@font-face`,
1419/// `{`, and `}` boundaries are ASCII, so replacing the whole block range with
1420/// spaces preserves UTF-8 validity (any multi-byte family name inside the block
1421/// is fully within the replaced range).
1422fn mask_font_face_blocks(lower_source: &str) -> String {
1423    if !lower_source.contains("@font-face") {
1424        return lower_source.to_owned();
1425    }
1426    let mut bytes = lower_source.as_bytes().to_vec();
1427    let sb = lower_source.as_bytes();
1428    let mut search = 0;
1429    while let Some(rel) = lower_source[search..].find("@font-face") {
1430        let start = search + rel;
1431        let Some(brace_rel) = lower_source[start..].find('{') else {
1432            break;
1433        };
1434        let mut depth = 0usize;
1435        let mut j = start + brace_rel;
1436        while j < sb.len() {
1437            match sb[j] {
1438                b'{' => depth += 1,
1439                b'}' => {
1440                    depth -= 1;
1441                    if depth == 0 {
1442                        break;
1443                    }
1444                }
1445                _ => {}
1446            }
1447            j += 1;
1448        }
1449        let end = (j + 1).min(bytes.len());
1450        for b in &mut bytes[start..end] {
1451            *b = b' ';
1452        }
1453        search = end;
1454    }
1455    String::from_utf8(bytes).unwrap_or_else(|_| lower_source.to_owned())
1456}
1457
1458/// Of the candidate unused `@font-face` families, the subset whose name appears
1459/// as a substring in some other source file (`.css`/`.scss`/`.sass`/`.less`,
1460/// JS/TS, or markup), OUTSIDE its own `@font-face` block. Such a family is
1461/// applied somewhere the structural `font-family` reference set cannot see (a
1462/// Tailwind v4 `--font-*` theme token in a `@theme` block lightningcss skips, a
1463/// `.scss` theme, a canvas/JS `fontFamily` assignment, an inline style), so it
1464/// is NOT dead.
1465fn font_families_referenced_in_source(
1466    candidates: &[fallow_output::UnusedFontFace],
1467    files: &[fallow_types::discover::DiscoveredFile],
1468    config: &ResolvedConfig,
1469    ignore_set: &globset::GlobSet,
1470) -> rustc_hash::FxHashSet<String> {
1471    // `(original-case family, lowercase family)`; the lowercase form drives the
1472    // substring test because CSS font-family names are case-insensitive, while the
1473    // original case is what gets returned for the caller's retain.
1474    let mut pending: Vec<(String, String)> = candidates
1475        .iter()
1476        .map(|c| (c.family.clone(), c.family.to_ascii_lowercase()))
1477        .collect();
1478    let mut found: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
1479    for file in files {
1480        if pending.is_empty() {
1481            break;
1482        }
1483        let path = &file.path;
1484        let extension = path.extension().and_then(|ext| ext.to_str());
1485        if !matches!(
1486            extension,
1487            Some(
1488                "css"
1489                    | "scss"
1490                    | "sass"
1491                    | "less"
1492                    | "js"
1493                    | "jsx"
1494                    | "ts"
1495                    | "tsx"
1496                    | "mjs"
1497                    | "cjs"
1498                    | "vue"
1499                    | "svelte"
1500                    | "astro"
1501                    | "html"
1502                    | "mdx"
1503            )
1504        ) {
1505            continue;
1506        }
1507        let relative = path.strip_prefix(&config.root).unwrap_or(path);
1508        if ignore_set.is_match(relative) {
1509            continue;
1510        }
1511        let Ok(source) = std::fs::read_to_string(path) else {
1512            continue;
1513        };
1514        // `.css` is scanned too: a family can be referenced via a custom-property
1515        // value (a Tailwind v4 `--font-*` theme token, which lives inside a
1516        // `@theme` block that lightningcss skips, so the structural reference set
1517        // never sees it). The family's OWN `@font-face` definition is masked so it
1518        // does not self-credit (every declared family appears in its own block).
1519        let source_lower = mask_font_face_blocks(&source.to_ascii_lowercase());
1520        pending.retain(|(family, family_lower)| {
1521            if source_lower.contains(family_lower.as_str()) {
1522                found.insert(family.clone());
1523                false
1524            } else {
1525                true
1526            }
1527        });
1528    }
1529    found
1530}
1531
1532/// Shortest global class worth reporting as unreferenced. Shorter names are
1533/// substring-prone (their literal appears inside many longer strings, so the
1534/// substring reference check already keeps them safe) and low-signal.
1535const MIN_UNREF_CLASS_LEN: usize = 5;
1536
1537/// Shortest a dependency's normalized name may be to serve as a third-party
1538/// class-prefix abstain key. Below this a short package name (`vue`, `css`)
1539/// would swallow too many real authored classes.
1540const MIN_DEP_PREFIX_LEN: usize = 6;
1541
1542/// Normalize an identifier to a run of lowercase ASCII alphanumerics (drop
1543/// scopes, hyphens, dots): `maplibre-gl` -> `maplibregl`, `@scope/pkg` keeps
1544/// only `pkg` because the caller de-scopes first.
1545fn normalize_dep_token(name: &str) -> String {
1546    name.chars()
1547        .filter(char::is_ascii_alphanumeric)
1548        .map(|c| c.to_ascii_lowercase())
1549        .collect()
1550}
1551
1552/// Normalized names of the project's declared dependencies (length-floored),
1553/// used to abstain on third-party CSS classes a library applies to its own
1554/// runtime-created DOM (e.g. a `.maplibregl-*` rule that styles the
1555/// `maplibre-gl` library). Scoped packages are de-scoped to the bare name.
1556fn dependency_class_prefixes(config: &ResolvedConfig) -> rustc_hash::FxHashSet<String> {
1557    let mut prefixes: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
1558    let Ok(text) = std::fs::read_to_string(config.root.join("package.json")) else {
1559        return prefixes;
1560    };
1561    let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) else {
1562        return prefixes;
1563    };
1564    for key in ["dependencies", "devDependencies", "peerDependencies"] {
1565        if let Some(deps) = json.get(key).and_then(serde_json::Value::as_object) {
1566            for name in deps.keys() {
1567                // De-scope `@scope/pkg` -> `pkg` so the prefix is the package's
1568                // own name, not the scope.
1569                let bare = name.rsplit('/').next().unwrap_or(name);
1570                let normalized = normalize_dep_token(bare);
1571                if normalized.len() >= MIN_DEP_PREFIX_LEN {
1572                    prefixes.insert(normalized);
1573                }
1574            }
1575        }
1576    }
1577    prefixes
1578}
1579
1580/// True when a CSS class is a third-party library's class: its normalized form
1581/// starts with a declared dependency's normalized name. `maplibregl-popup-content`
1582/// -> `maplibreglpopupcontent` starts with `maplibregl`. Conservative
1583/// (abstain-leaning): a third-party class wrongly flagged dead is a far worse
1584/// candidate than a missed authored dead class.
1585fn class_matches_dependency_prefix(
1586    class: &str,
1587    dependency_prefixes: &rustc_hash::FxHashSet<String>,
1588) -> bool {
1589    if dependency_prefixes.is_empty() {
1590        return false;
1591    }
1592    let normalized = normalize_dep_token(class);
1593    dependency_prefixes
1594        .iter()
1595        .any(|prefix| normalized.starts_with(prefix.as_str()))
1596}
1597
1598/// Extract class-shaped tokens from quoted string literals (`'...'` / `"..."` /
1599/// `` `...` ``) in a source string and add them to `out`, crediting a name
1600/// applied outside a `class=` / `className=` attribute (a config-object
1601/// `className: 'leveret-toast'`, a helper `return "x-y"`, a JS inline-style
1602/// `animation: 'progress-indeterminate 1s'`).
1603///
1604/// `require_dash` controls strictness. For CLASS crediting it is `true`: only
1605/// compound (dash-bearing) tokens are taken, so a generic single word never
1606/// coincidentally credits a class and breaks the whole-sheet abstain that
1607/// protects classes used in a surface fallow cannot read (Phoenix `.heex`). For
1608/// KEYFRAME crediting it is `false` (the caller filters to actually-defined
1609/// keyframes, so over-extraction is inert), letting a single-word keyframe name
1610/// (`spin`, `jsanim`) be credited from a JS `animation:` string too.
1611fn collect_quoted_class_tokens(
1612    source: &str,
1613    out: &mut rustc_hash::FxHashSet<String>,
1614    require_dash: bool,
1615) {
1616    let bytes = source.as_bytes();
1617    let mut i = 0;
1618    while i < bytes.len() {
1619        let quote = bytes[i];
1620        if quote == b'"' || quote == b'\'' || quote == b'`' {
1621            let start = i + 1;
1622            let mut j = start;
1623            while j < bytes.len() && bytes[j] != quote {
1624                j += 1;
1625            }
1626            if let Some(content) = source.get(start..j) {
1627                for token in content
1628                    .split(|c: char| !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'))
1629                {
1630                    let shaped = token.as_bytes().first().is_some_and(u8::is_ascii_lowercase)
1631                        && !token.ends_with('-')
1632                        && (if require_dash {
1633                            token.contains('-')
1634                        } else {
1635                            token.len() >= 3
1636                        });
1637                    if shaped {
1638                        out.insert(token.to_owned());
1639                    }
1640                }
1641            }
1642            i = j + 1;
1643        } else {
1644            i += 1;
1645        }
1646    }
1647}
1648
1649/// Class names wrapped in a CSS Modules `:global(...)` selector. Such a class is
1650/// applied by code OUTSIDE this stylesheet, most often a third-party library's
1651/// runtime DOM that the module styles via an escape hatch (an antd
1652/// `.validatiemeldingenModal :global(.ant-modal-header)` override). The project's
1653/// own markup never writes that class, so it can never be credited and would
1654/// always surface as a (false) unreferenced-class candidate. `:global` is the
1655/// author's explicit "not locally scoped, applied elsewhere" marker, so excluding
1656/// these from the candidate set is semantically correct, not a heuristic guess.
1657fn collect_global_scoped_classes(source: &str, out: &mut rustc_hash::FxHashSet<String>) {
1658    let bytes = source.as_bytes();
1659    let mut i = 0;
1660    while let Some(rel) = source[i..].find(":global(") {
1661        let open = i + rel + ":global(".len();
1662        // Balance parentheses so a `:global(:is(.a, .b))` still closes correctly.
1663        let mut depth = 1usize;
1664        let mut j = open;
1665        while j < bytes.len() && depth > 0 {
1666            match bytes[j] {
1667                b'(' => depth += 1,
1668                b')' => depth -= 1,
1669                _ => {}
1670            }
1671            j += 1;
1672        }
1673        let inner_end = j.saturating_sub(1).max(open);
1674        if let Some(inner) = source.get(open..inner_end) {
1675            extract_dotted_class_names(inner, out);
1676        }
1677        i = j.max(open + 1);
1678    }
1679}
1680
1681/// Push every `.class` token in a CSS selector fragment (the bare name, no dot)
1682/// into `out`. A class name is a dot followed by `[A-Za-z_-]` then any run of
1683/// `[A-Za-z0-9_-]`.
1684fn extract_dotted_class_names(selector: &str, out: &mut rustc_hash::FxHashSet<String>) {
1685    let bytes = selector.as_bytes();
1686    let mut i = 0;
1687    while i < bytes.len() {
1688        if bytes[i] == b'.' {
1689            let start = i + 1;
1690            if start < bytes.len()
1691                && (bytes[start].is_ascii_alphabetic() || matches!(bytes[start], b'_' | b'-'))
1692            {
1693                let mut j = start;
1694                while j < bytes.len()
1695                    && (bytes[j].is_ascii_alphanumeric() || matches!(bytes[j], b'_' | b'-'))
1696                {
1697                    j += 1;
1698                }
1699                if let Some(name) = selector.get(start..j) {
1700                    out.insert(name.to_owned());
1701                }
1702                i = j;
1703                continue;
1704            }
1705        }
1706        i += 1;
1707    }
1708}
1709
1710/// Per-stylesheet located class definitions from STANDALONE `.css`/`.scss` files
1711/// (not SFC `<style>` blocks, which are component-scoped and covered by the
1712/// scoped-unused check). Returns `(rel_path, [(class, 1-based line)])`, each
1713/// class deduped to its first definition. The defined surface for the
1714/// unreferenced-global-class candidate. Classes wrapped in `:global(...)` are
1715/// dropped: they target externally-applied DOM and are never authored in markup.
1716fn collect_defined_css_classes_located(
1717    files: &[fallow_types::discover::DiscoveredFile],
1718    config: &ResolvedConfig,
1719    ignore_set: &globset::GlobSet,
1720) -> Vec<(String, Vec<(String, u32)>)> {
1721    use fallow_types::extract::ExportName;
1722    let mut out: Vec<(String, Vec<(String, u32)>)> = Vec::new();
1723    for file in files {
1724        let path = &file.path;
1725        let extension = path.extension().and_then(|ext| ext.to_str());
1726        let is_scss = extension == Some("scss");
1727        if extension != Some("css") && !is_scss {
1728            continue;
1729        }
1730        let relative = path.strip_prefix(&config.root).unwrap_or(path);
1731        if ignore_set.is_match(relative) {
1732            continue;
1733        }
1734        let Ok(source) = std::fs::read_to_string(path) else {
1735            continue;
1736        };
1737        let mut global_scoped: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
1738        collect_global_scoped_classes(&source, &mut global_scoped);
1739        let mut seen: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
1740        let mut classes: Vec<(String, u32)> = Vec::new();
1741        for export in crate::extract::extract_css_module_exports(&source, is_scss) {
1742            let ExportName::Named(name) = export.name else {
1743                continue;
1744            };
1745            // A `:global(.foo)` override targets DOM applied outside this module
1746            // (a third-party library's runtime markup), so it is never authored in
1747            // project markup and must not be an unreferenced-class candidate.
1748            if global_scoped.contains(&name) {
1749                continue;
1750            }
1751            if !seen.insert(name.clone()) {
1752                continue;
1753            }
1754            let start = export.span.start as usize;
1755            let line = 1 + source
1756                .get(..start)
1757                .map_or(0, |s| s.bytes().filter(|&b| b == b'\n').count());
1758            classes.push((name, u32::try_from(line).unwrap_or(u32::MAX)));
1759        }
1760        if !classes.is_empty() {
1761            out.push((relative.to_string_lossy().replace('\\', "/"), classes));
1762        }
1763    }
1764    out
1765}
1766
1767/// Project-root-relative CSS/SCSS paths published as a package entry
1768/// (`style` / `main` / `sass` / `module`, or any string ending in `.css`/`.scss`
1769/// anywhere in `exports`). A stylesheet on this list is a public surface
1770/// consumed by OTHER repos, so its classes are referenced externally and must
1771/// never be flagged unreferenced.
1772fn published_css_paths(config: &ResolvedConfig) -> rustc_hash::FxHashSet<String> {
1773    let mut published: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
1774    let Ok(text) = std::fs::read_to_string(config.root.join("package.json")) else {
1775        return published;
1776    };
1777    let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) else {
1778        return published;
1779    };
1780    let normalize = |s: &str| s.trim_start_matches("./").replace('\\', "/");
1781    let is_css = |s: &str| {
1782        matches!(
1783            std::path::Path::new(s)
1784                .extension()
1785                .and_then(|e| e.to_str())
1786                .map(str::to_ascii_lowercase)
1787                .as_deref(),
1788            Some("css" | "scss")
1789        )
1790    };
1791    for key in ["style", "main", "sass", "module"] {
1792        if let Some(s) = json.get(key).and_then(serde_json::Value::as_str)
1793            && is_css(s)
1794        {
1795            published.insert(normalize(s));
1796        }
1797    }
1798    // Walk `exports` (arbitrarily nested) collecting every CSS string value.
1799    let mut stack = vec![
1800        json.get("exports")
1801            .cloned()
1802            .unwrap_or(serde_json::Value::Null),
1803    ];
1804    while let Some(node) = stack.pop() {
1805        match node {
1806            serde_json::Value::String(s) if is_css(&s) => {
1807                published.insert(normalize(&s));
1808            }
1809            serde_json::Value::Array(items) => stack.extend(items),
1810            serde_json::Value::Object(map) => stack.extend(map.into_values()),
1811            _ => {}
1812        }
1813    }
1814    published
1815}
1816
1817/// Scan for global CSS classes referenced by NO in-project markup (the CSS
1818/// analogue of an unused export). Heavily gated to stay near-zero-false-positive:
1819///
1820/// - **Partial scope** (`changed_files` / `ws_roots`): abstain. A partial markup
1821///   view cannot prove a global class dead.
1822/// - **Preprocessor-dominant** (`.scss`/`.sass`/`.less` outnumber plain `.css`):
1823///   abstain. The parser cannot expand loops/mixins, so the markup-vs-CSS join
1824///   is unreliable.
1825/// - **Published surface**: a stylesheet reachable from `package.json` entries,
1826///   or whose classes are referenced by zero in-project markup (a design system
1827///   consumed elsewhere), abstains entirely.
1828/// - **Reference test** (panel gate 1): a class is referenced if it is a whole
1829///   static markup token OR a substring of any dynamic-class source, so a class
1830///   assembled from a `${...}` / `clsx(...)` fragment is never flagged.
1831fn scan_unreferenced_css_classes(
1832    files: &[fallow_types::discover::DiscoveredFile],
1833    ctx: HealthScanCtx<'_>,
1834    summary: &mut fallow_output::CssAnalyticsSummary,
1835) -> Vec<fallow_output::UnreferencedCssClass> {
1836    let HealthScanCtx {
1837        config,
1838        ignore_set,
1839        changed_files,
1840        ws_roots,
1841    } = ctx;
1842
1843    use fallow_output::UnreferencedCssClass;
1844
1845    // Partial scope cannot prove a global class dead.
1846    if changed_files.is_some() || ws_roots.is_some() {
1847        return Vec::new();
1848    }
1849    // Preprocessor-dominant projects have an unreliable defined/used join.
1850    let (css_files, preprocessor_files) = count_stylesheet_kinds(files, config, ignore_set);
1851    if preprocessor_files > css_files {
1852        return Vec::new();
1853    }
1854
1855    let reference_surface = css_reference_surface(files, config, ignore_set);
1856
1857    let published = published_css_paths(config);
1858    let dependency_prefixes = dependency_class_prefixes(config);
1859    let located = collect_defined_css_classes_located(files, config, ignore_set);
1860
1861    let mut out: Vec<UnreferencedCssClass> = Vec::new();
1862    for (rel, classes) in located {
1863        push_unreferenced_css_class_candidates(
1864            &mut out,
1865            &rel,
1866            classes,
1867            &published,
1868            &dependency_prefixes,
1869            &reference_surface,
1870        );
1871    }
1872
1873    out.sort_by(|a, b| {
1874        a.path
1875            .cmp(&b.path)
1876            .then_with(|| a.line.cmp(&b.line))
1877            .then_with(|| a.class.cmp(&b.class))
1878    });
1879    summary.unreferenced_css_classes = saturate_len(out.len());
1880    out
1881}
1882
1883struct CssReferenceSurface {
1884    static_tokens: rustc_hash::FxHashSet<String>,
1885    dynamic_corpus: String,
1886}
1887
1888impl CssReferenceSurface {
1889    fn references(&self, class: &str) -> bool {
1890        self.static_tokens.contains(class)
1891            || self.dynamic_corpus.contains(class)
1892            || self.dynamic_prefix_referenced(class)
1893    }
1894
1895    fn dynamic_prefix_referenced(&self, class: &str) -> bool {
1896        let Some(dash) = class.rfind('-') else {
1897            return false;
1898        };
1899        let head = &class[..=dash];
1900        const INTERP_MARKERS: [&str; 6] = ["${", "' +", "'+", "\" +", "\"+", "` +"];
1901        INTERP_MARKERS
1902            .iter()
1903            .any(|marker| self.dynamic_corpus.contains(&format!("{head}{marker}")))
1904    }
1905}
1906
1907fn css_reference_surface(
1908    files: &[fallow_types::discover::DiscoveredFile],
1909    config: &ResolvedConfig,
1910    ignore_set: &globset::GlobSet,
1911) -> CssReferenceSurface {
1912    let mut surface = CssReferenceSurface {
1913        static_tokens: rustc_hash::FxHashSet::default(),
1914        dynamic_corpus: String::new(),
1915    };
1916    for file in files {
1917        collect_css_reference_surface_file(&mut surface, file, config, ignore_set);
1918    }
1919    surface
1920}
1921
1922fn collect_css_reference_surface_file(
1923    surface: &mut CssReferenceSurface,
1924    file: &fallow_types::discover::DiscoveredFile,
1925    config: &ResolvedConfig,
1926    ignore_set: &globset::GlobSet,
1927) {
1928    let path = &file.path;
1929    let extension = path.extension().and_then(|ext| ext.to_str());
1930    if !matches!(
1931        extension,
1932        Some("jsx" | "tsx" | "html" | "astro" | "vue" | "svelte")
1933    ) {
1934        return;
1935    }
1936    let relative = path.strip_prefix(&config.root).unwrap_or(path);
1937    if ignore_set.is_match(relative) {
1938        return;
1939    }
1940    let Ok(source) = std::fs::read_to_string(path) else {
1941        return;
1942    };
1943    let scan = crate::extract::scan_markup_class_tokens(&source);
1944    for token in scan.static_tokens {
1945        surface.static_tokens.insert(token.value);
1946    }
1947    collect_quoted_class_tokens(&source, &mut surface.static_tokens, true);
1948    if scan.has_dynamic {
1949        surface.dynamic_corpus.push_str(&source);
1950        surface.dynamic_corpus.push('\n');
1951    }
1952}
1953
1954fn push_unreferenced_css_class_candidates(
1955    out: &mut Vec<fallow_output::UnreferencedCssClass>,
1956    rel: &str,
1957    classes: Vec<(String, u32)>,
1958    published: &rustc_hash::FxHashSet<String>,
1959    dependency_prefixes: &rustc_hash::FxHashSet<String>,
1960    reference_surface: &CssReferenceSurface,
1961) {
1962    use fallow_output::{CssCandidateAction, UnreferencedCssClass};
1963
1964    if published.contains(rel)
1965        || !classes
1966            .iter()
1967            .any(|(class, _)| reference_surface.references(class))
1968    {
1969        return;
1970    }
1971    for (class, line) in classes {
1972        if class.len() >= MIN_UNREF_CLASS_LEN
1973            && !reference_surface.references(&class)
1974            && !class_matches_dependency_prefix(&class, dependency_prefixes)
1975        {
1976            out.push(UnreferencedCssClass {
1977                actions: vec![CssCandidateAction::verify_unreferenced_class(&class)],
1978                class,
1979                path: rel.to_string(),
1980                line,
1981            });
1982        }
1983    }
1984}
1985
1986/// Source-file extensions scanned for Tailwind utility-class-shaped tokens when
1987/// crediting `@theme` token usage. Mirrors the font-family source scan (markup,
1988/// JS/TS className strings / `clsx` args / CSS-in-JS, preprocessor stylesheets)
1989/// but deliberately EXCLUDES plain `.css`, which would re-read the `@theme`
1990/// DEFINITION and self-credit every token.
1991const THEME_USAGE_SOURCE_EXTS: &[&str] = &[
1992    "scss", "sass", "less", "js", "jsx", "ts", "tsx", "mjs", "cjs", "vue", "svelte", "astro",
1993    "html", "mdx",
1994];
1995
1996/// Collect every Tailwind-utility-shaped token from `source` into `out`: a
1997/// maximal run of `[a-z0-9-]` that, with leading/trailing `-` trimmed, still
1998/// contains a `-` and starts with a lowercase letter. Captures `bg-brand`,
1999/// `rounded-card`, `text-2xl`, and the `color-brand` core of a
2000/// `var(--color-brand)` / `[--color-brand]` reference. Deliberately captures the
2001/// dashed SHAPE, never a bare word, so a dictionary-word theme name
2002/// (`brand`/`card`/`muted`) is credited only by a real `-<name>` utility suffix,
2003/// not by the word appearing anywhere in source.
2004fn collect_class_shaped_tokens(source: &str, out: &mut rustc_hash::FxHashSet<String>) {
2005    let bytes = source.as_bytes();
2006    let mut i = 0;
2007    while i < bytes.len() {
2008        let b = bytes[i];
2009        if b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' {
2010            let start = i;
2011            while i < bytes.len() {
2012                let c = bytes[i];
2013                if c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-' {
2014                    i += 1;
2015                } else {
2016                    break;
2017                }
2018            }
2019            let tok = source[start..i].trim_matches('-');
2020            if tok.contains('-') && tok.as_bytes().first().is_some_and(u8::is_ascii_lowercase) {
2021                out.insert(tok.to_owned());
2022            }
2023        } else {
2024            i += 1;
2025        }
2026    }
2027}
2028
2029/// True when a `tailwind.config.*` text declares a non-empty `plugins` array
2030/// (`plugins: [ <non-empty> ]`). Used by the unused-theme-token plugin abstain.
2031/// Whitespace-tolerant, conservative (abstain-leaning): any `plugins` key whose
2032/// next non-whitespace tokens are `:` `[` `<non-`]`>` counts.
2033fn text_has_nonempty_plugins_array(text: &str) -> bool {
2034    let bytes = text.as_bytes();
2035    let skip_ws = |mut k: usize| {
2036        while k < bytes.len() && bytes[k].is_ascii_whitespace() {
2037            k += 1;
2038        }
2039        k
2040    };
2041    let mut from = 0;
2042    while let Some(rel) = text[from..].find("plugins") {
2043        let mut k = skip_ws(from + rel + "plugins".len());
2044        if k < bytes.len() && bytes[k] == b':' {
2045            k = skip_ws(k + 1);
2046            if k < bytes.len() && bytes[k] == b'[' {
2047                k = skip_ws(k + 1);
2048                if k < bytes.len() && bytes[k] != b']' {
2049                    return true;
2050                }
2051            }
2052        }
2053        from = from + rel + "plugins".len();
2054    }
2055    false
2056}
2057
2058/// True when the project declares a Tailwind plugin: a `@plugin` directive in any
2059/// stylesheet (already accumulated) OR a `tailwind.config.*` with a non-empty
2060/// `plugins` array. A plugin can consume `@theme` tokens via `theme()` /
2061/// `addUtilities` invisibly to the markup / CSS / `var()` scan, so the
2062/// unused-theme-token candidate hard-abstains on plugin projects.
2063fn project_uses_tailwind_plugin(any_plugin_directive: bool, root: &std::path::Path) -> bool {
2064    if any_plugin_directive {
2065        return true;
2066    }
2067    for name in [
2068        "tailwind.config.js",
2069        "tailwind.config.ts",
2070        "tailwind.config.mjs",
2071        "tailwind.config.cjs",
2072        "tailwind.config.mts",
2073        "tailwind.config.cts",
2074    ] {
2075        if let Ok(text) = std::fs::read_to_string(root.join(name))
2076            && text_has_nonempty_plugins_array(&text)
2077        {
2078            return true;
2079        }
2080    }
2081    false
2082}
2083
2084/// Tailwind v4 `@theme` design tokens (`--color-brand`, `--radius-card`) defined
2085/// in a stylesheet but used by no generated utility, `var()` read, `@apply`, or
2086/// arbitrary value anywhere in the project: dead design tokens (the
2087/// `unused-export` of the token era). Heavily gated to stay near-zero-false-
2088/// positive (panel BLOCKs):
2089///
2090/// - **Partial scope** (`changed_files` / `ws_roots`): abstain. A partial view
2091///   cannot prove a token dead.
2092/// - **v4 gate**: emit only when the project declares a `tailwindcss` dependency
2093///   AND at least one `@theme` token was found.
2094/// - **Tailwind plugin** (`@plugin` / config `plugins[]`): abstain. A plugin can
2095///   consume tokens invisibly to the scan (the DI blind spot).
2096/// - **Published library**: a token defined in a stylesheet that is a published
2097///   package surface is a public design-token API consumed downstream; skip it.
2098/// - **Variant namespaces** (`--breakpoint-*` / `--container-*`): excluded from
2099///   candidacy in this version. Crediting their `<name>:` / `@<name>:` variant
2100///   usage robustly needs a dedicated variant parser; a follow-up can add it.
2101///   (Acceptance criterion 7: excluded when the variant scan is not built.)
2102///
2103/// The usage test is false-negative-leaning by design: every check CREDITS usage,
2104/// so a genuinely-dead token is missed before a live one is flagged.
2105struct UnusedThemeTokenScanInput<'a> {
2106    tokens: &'a CssTokenSets,
2107    files: &'a [fallow_types::discover::DiscoveredFile],
2108    config: &'a ResolvedConfig,
2109    ignore_set: &'a globset::GlobSet,
2110    changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
2111    ws_roots: Option<&'a [std::path::PathBuf]>,
2112    summary: &'a mut fallow_output::CssAnalyticsSummary,
2113}
2114
2115/// A classified `@theme` token candidate (namespace + name + definition site)
2116/// surviving the variant / published-library / unknown-namespace filters.
2117struct ThemeTokenCandidate {
2118    token: String,
2119    namespace: String,
2120    name: String,
2121    path: String,
2122    line: u32,
2123}
2124
2125/// Classify the project's `@theme` token definers, dropping variant namespaces,
2126/// published-library stylesheets, and anything outside a known namespace.
2127fn classify_theme_token_candidates(
2128    input: &UnusedThemeTokenScanInput<'_>,
2129) -> Vec<ThemeTokenCandidate> {
2130    let published = published_css_paths(input.config);
2131    let mut candidates: Vec<ThemeTokenCandidate> = Vec::new();
2132    for (raw, (path, line)) in &input.tokens.theme_token_definers {
2133        if published.contains(path) {
2134            continue;
2135        }
2136        let Some(classified) = tailwind_theme::classify(raw) else {
2137            continue;
2138        };
2139        if classified.is_variant {
2140            continue;
2141        }
2142        candidates.push(ThemeTokenCandidate {
2143            token: format!("--{raw}"),
2144            namespace: classified.namespace,
2145            name: classified.name,
2146            path: path.clone(),
2147            line: *line,
2148        });
2149    }
2150    candidates
2151}
2152
2153/// Build the utility-shaped usage surface: every class-shaped token from `@apply`
2154/// bodies plus non-CSS source (markup class attributes, `clsx` args, CSS-in-JS).
2155fn collect_theme_usage_tokens(
2156    input: &UnusedThemeTokenScanInput<'_>,
2157) -> rustc_hash::FxHashSet<String> {
2158    let mut utility_tokens: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
2159    for apply in &input.tokens.apply_tokens {
2160        collect_class_shaped_tokens(apply, &mut utility_tokens);
2161    }
2162    for file in input.files {
2163        let path = &file.path;
2164        let extension = path.extension().and_then(|ext| ext.to_str());
2165        if !extension.is_some_and(|ext| THEME_USAGE_SOURCE_EXTS.contains(&ext)) {
2166            continue;
2167        }
2168        let relative = path.strip_prefix(&input.config.root).unwrap_or(path);
2169        if input.ignore_set.is_match(relative) {
2170            continue;
2171        }
2172        if let Ok(source) = std::fs::read_to_string(path) {
2173            collect_class_shaped_tokens(&source, &mut utility_tokens);
2174        }
2175    }
2176    utility_tokens
2177}
2178
2179/// The `var()` read surface: CSS-side `@theme` reads plus referenced custom
2180/// properties (leading dashes trimmed to the property key form).
2181fn collect_theme_var_reads(tokens: &CssTokenSets) -> rustc_hash::FxHashSet<String> {
2182    let mut var_reads: rustc_hash::FxHashSet<String> = tokens.theme_var_reads.clone();
2183    for referenced in &tokens.referenced_custom_props {
2184        var_reads.insert(referenced.trim_start_matches('-').to_owned());
2185    }
2186    var_reads
2187}
2188
2189fn scan_unused_theme_tokens(
2190    input: &mut UnusedThemeTokenScanInput<'_>,
2191) -> Vec<fallow_output::UnusedThemeToken> {
2192    use fallow_output::{CssCandidateAction, UnusedThemeToken};
2193
2194    // Partial scope cannot prove a token dead.
2195    if input.changed_files.is_some() || input.ws_roots.is_some() {
2196        return Vec::new();
2197    }
2198    // v4 gate: a Tailwind dependency AND at least one @theme token present.
2199    if input.tokens.theme_token_definers.is_empty() || !project_uses_tailwind(&input.config.root) {
2200        return Vec::new();
2201    }
2202    // Tailwind-plugin abstain (DI blind spot).
2203    if project_uses_tailwind_plugin(input.tokens.any_plugin_directive, &input.config.root) {
2204        return Vec::new();
2205    }
2206
2207    let candidates = classify_theme_token_candidates(input);
2208    if candidates.is_empty() {
2209        input.summary.unused_theme_tokens = 0;
2210        return Vec::new();
2211    }
2212
2213    let utility_tokens = collect_theme_usage_tokens(input);
2214    let var_reads = collect_theme_var_reads(input.tokens);
2215
2216    let mut out: Vec<UnusedThemeToken> = Vec::new();
2217    for candidate in candidates {
2218        let dash_name = format!("-{}", candidate.name);
2219        // The token's own custom-property key, used by the var() read test.
2220        let raw = candidate.token.trim_start_matches('-');
2221        let used = var_reads.contains(raw)
2222            || utility_tokens
2223                .iter()
2224                .any(|t| t.len() > dash_name.len() && t.ends_with(&dash_name));
2225        if used {
2226            continue;
2227        }
2228        out.push(UnusedThemeToken {
2229            actions: vec![CssCandidateAction::verify_unused_theme_token(
2230                &candidate.token,
2231                &candidate.namespace,
2232                &candidate.name,
2233            )],
2234            token: candidate.token,
2235            namespace: candidate.namespace,
2236            path: candidate.path,
2237            line: candidate.line,
2238        });
2239    }
2240    out.sort_by(|a, b| {
2241        a.path
2242            .cmp(&b.path)
2243            .then_with(|| a.line.cmp(&b.line))
2244            .then_with(|| a.token.cmp(&b.token))
2245    });
2246    input.summary.unused_theme_tokens = saturate_len(out.len());
2247    out
2248}
2249
2250/// The markup / source-derived CSS candidate lists, gathered in one pass-set so
2251/// the orchestrator stays a thin assembler.
2252struct MarkupCssCandidates {
2253    tailwind_arbitrary_values: Vec<fallow_output::TailwindArbitraryValue>,
2254    unresolved_class_references: Vec<fallow_output::UnresolvedClassReference>,
2255    unreferenced_css_classes: Vec<fallow_output::UnreferencedCssClass>,
2256    unused_theme_tokens: Vec<fallow_output::UnusedThemeToken>,
2257}
2258
2259/// Run the markup / source-scanning CSS candidates (Tailwind arbitrary values,
2260/// likely class typos, unreferenced global classes, unused `@theme` tokens),
2261/// each honoring the same ignore / changed / workspace filters and setting its
2262/// own summary counts.
2263struct MarkupCssCandidateInput<'a> {
2264    tokens: &'a CssTokenSets,
2265    files: &'a [fallow_types::discover::DiscoveredFile],
2266    config: &'a ResolvedConfig,
2267    ignore_set: &'a globset::GlobSet,
2268    changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
2269    ws_roots: Option<&'a [std::path::PathBuf]>,
2270    summary: &'a mut fallow_output::CssAnalyticsSummary,
2271}
2272
2273fn scan_markup_css_candidates(input: &mut MarkupCssCandidateInput<'_>) -> MarkupCssCandidates {
2274    MarkupCssCandidates {
2275        // Markup arbitrary-value scan (gated on the project using Tailwind).
2276        tailwind_arbitrary_values: scan_markup_tailwind_arbitrary_values(
2277            input.files,
2278            HealthScanCtx {
2279                config: input.config,
2280                ignore_set: input.ignore_set,
2281                changed_files: input.changed_files,
2282                ws_roots: input.ws_roots,
2283            },
2284            input.summary,
2285        ),
2286        // Static markup class tokens one edit from a defined class (likely typos).
2287        unresolved_class_references: scan_unresolved_class_references(
2288            input.files,
2289            HealthScanCtx {
2290                config: input.config,
2291                ignore_set: input.ignore_set,
2292                changed_files: input.changed_files,
2293                ws_roots: input.ws_roots,
2294            },
2295            input.summary,
2296        ),
2297        // Global classes referenced by no in-project markup (heavily gated).
2298        unreferenced_css_classes: scan_unreferenced_css_classes(
2299            input.files,
2300            HealthScanCtx {
2301                config: input.config,
2302                ignore_set: input.ignore_set,
2303                changed_files: input.changed_files,
2304                ws_roots: input.ws_roots,
2305            },
2306            input.summary,
2307        ),
2308        // Tailwind v4 @theme design tokens used by no utility / var() / @apply
2309        // anywhere (heavily gated: v4 + non-plugin + non-published + whole-scope).
2310        unused_theme_tokens: scan_unused_theme_tokens(&mut UnusedThemeTokenScanInput {
2311            tokens: input.tokens,
2312            files: input.files,
2313            config: input.config,
2314            ignore_set: input.ignore_set,
2315            changed_files: input.changed_files,
2316            ws_roots: input.ws_roots,
2317            summary: input.summary,
2318        }),
2319    }
2320}
2321
2322fn css_report_scan_target<'a>(
2323    file: &'a fallow_types::discover::DiscoveredFile,
2324    ctx: HealthScanCtx<'_>,
2325) -> Option<(&'a std::path::Path, bool)> {
2326    let HealthScanCtx {
2327        config,
2328        ignore_set,
2329        changed_files,
2330        ws_roots,
2331    } = ctx;
2332
2333    let path = &file.path;
2334    let extension = path.extension().and_then(|ext| ext.to_str());
2335    let is_css = extension == Some("css");
2336    let is_sfc = matches!(extension, Some("vue") | Some("svelte"));
2337    if !is_css && !is_sfc {
2338        return None;
2339    }
2340
2341    let relative = path.strip_prefix(&config.root).unwrap_or(path);
2342    if ignore_set.is_match(relative) {
2343        return None;
2344    }
2345    if let Some(changed) = changed_files
2346        && !changed.contains(path)
2347    {
2348        return None;
2349    }
2350    if let Some(roots) = ws_roots
2351        && !roots.iter().any(|root| path.starts_with(root))
2352    {
2353        return None;
2354    }
2355    Some((relative, is_sfc))
2356}
2357
2358fn record_scoped_unused_classes(
2359    source: &str,
2360    relative: &std::path::Path,
2361    summary: &mut fallow_output::CssAnalyticsSummary,
2362    scoped_unused: &mut Vec<fallow_output::ScopedUnusedClasses>,
2363) {
2364    let classes = crate::extract::scoped_unused_classes(source);
2365    if classes.is_empty() {
2366        return;
2367    }
2368
2369    summary.scoped_unused_classes = summary
2370        .scoped_unused_classes
2371        .saturating_add(u32::try_from(classes.len()).unwrap_or(u32::MAX));
2372    scoped_unused.push(fallow_output::ScopedUnusedClasses {
2373        path: relative.to_string_lossy().replace('\\', "/"),
2374        classes,
2375        actions: vec![fallow_output::CssCandidateAction::verify_scoped_classes()],
2376    });
2377}
2378
2379fn css_report_stylesheet_source(source: &str, is_sfc: bool) -> Option<std::borrow::Cow<'_, str>> {
2380    if is_sfc {
2381        return crate::extract::sfc_virtual_stylesheet(source).map(std::borrow::Cow::Owned);
2382    }
2383
2384    Some(std::borrow::Cow::Borrowed(source))
2385}
2386
2387fn record_css_analytics_summary(
2388    summary: &mut fallow_output::CssAnalyticsSummary,
2389    analytics: &fallow_types::extract::CssAnalytics,
2390) {
2391    summary.files_analyzed = summary.files_analyzed.saturating_add(1);
2392    summary.total_rules = summary.total_rules.saturating_add(analytics.rule_count);
2393    summary.total_declarations = summary
2394        .total_declarations
2395        .saturating_add(analytics.total_declarations);
2396    summary.important_declarations = summary
2397        .important_declarations
2398        .saturating_add(analytics.important_declarations);
2399    summary.empty_rules = summary
2400        .empty_rules
2401        .saturating_add(analytics.empty_rule_count);
2402    summary.max_nesting_depth = summary.max_nesting_depth.max(analytics.max_nesting_depth);
2403    if analytics.notable_truncated {
2404        summary.notable_truncated_files = summary.notable_truncated_files.saturating_add(1);
2405    }
2406}
2407
2408/// The per-file CSS walk accumulator: structural file reports, the project-wide
2409/// token sets, scoped SFC unused-class findings, and the running summary.
2410struct CssWalkAccum {
2411    file_reports: Vec<fallow_output::CssFileAnalytics>,
2412    summary: fallow_output::CssAnalyticsSummary,
2413    scoped_unused: Vec<fallow_output::ScopedUnusedClasses>,
2414    tokens: CssTokenSets,
2415}
2416
2417/// The finalized whole-project token metrics (keyframes, duplicate blocks, unused
2418/// at-rules, font-size unit mix, unused font faces) derived after the file walk.
2419struct CssTokenMetrics {
2420    unreferenced_keyframes: Vec<fallow_output::UnreferencedKeyframes>,
2421    undefined_keyframes: Vec<fallow_output::UndefinedKeyframes>,
2422    duplicate_declaration_blocks: Vec<fallow_output::CssDuplicateBlock>,
2423    unused_at_rules: Vec<fallow_output::UnusedAtRule>,
2424    font_size_unit_mix: Option<fallow_output::CssNotationConsistency>,
2425    unused_font_faces: Vec<fallow_output::UnusedFontFace>,
2426}
2427
2428/// Walk every in-scope stylesheet / SFC, accumulating structural metrics, the
2429/// project token sets, and scoped SFC unused-class findings.
2430fn walk_css_files(
2431    files: &[fallow_types::discover::DiscoveredFile],
2432    ctx: HealthScanCtx<'_>,
2433) -> CssWalkAccum {
2434    use fallow_output::{CssAnalyticsSummary, CssFileAnalytics, ScopedUnusedClasses};
2435
2436    let mut file_reports = Vec::new();
2437    let mut summary = CssAnalyticsSummary::default();
2438    let mut scoped_unused: Vec<ScopedUnusedClasses> = Vec::new();
2439    // Project-wide design-token + custom-property + @keyframes accumulator,
2440    // unioned across every analyzed stylesheet (including ones with no notable
2441    // rule, which are not listed individually), finalized after the walk.
2442    let mut tokens = CssTokenSets::default();
2443
2444    for file in files {
2445        let Some((relative, is_sfc)) = css_report_scan_target(file, ctx) else {
2446            continue;
2447        };
2448        let Ok(source) = std::fs::read_to_string(&file.path) else {
2449            continue;
2450        };
2451
2452        if is_sfc {
2453            record_scoped_unused_classes(&source, relative, &mut summary, &mut scoped_unused);
2454        }
2455
2456        // Vue/Svelte SFC `<style>` blocks are folded into a virtual stylesheet so
2457        // their structural metrics (specificity, !important, design tokens) count
2458        // the same as a standalone .css file; SFCs with only SCSS blocks yield None.
2459        let Some(css_source) = css_report_stylesheet_source(&source, is_sfc) else {
2460            continue;
2461        };
2462        let Some(analytics) = crate::extract::compute_css_analytics(&css_source) else {
2463            continue;
2464        };
2465
2466        let rel = relative.to_string_lossy().replace('\\', "/");
2467        record_css_analytics_summary(&mut summary, &analytics);
2468        tokens.record(&analytics, &rel);
2469        tokens.record_theme(css_source.as_ref(), &rel);
2470
2471        if !analytics.notable_rules.is_empty() {
2472            file_reports.push(CssFileAnalytics {
2473                path: rel,
2474                analytics,
2475            });
2476        }
2477    }
2478
2479    CssWalkAccum {
2480        file_reports,
2481        summary,
2482        scoped_unused,
2483        tokens,
2484    }
2485}
2486
2487/// Credit Tailwind-markup-applied keyframes, then finalize the whole-project
2488/// token metrics and prune unused `@font-face` families referenced elsewhere.
2489fn finalize_css_token_metrics(
2490    tokens: &mut CssTokenSets,
2491    summary: &mut fallow_output::CssAnalyticsSummary,
2492    files: &[fallow_types::discover::DiscoveredFile],
2493    config: &ResolvedConfig,
2494    ignore_set: &globset::GlobSet,
2495) -> CssTokenMetrics {
2496    // Credit @keyframes applied via Tailwind markup (`animate-[name_...]` /
2497    // `animate-name`), not just CSS `animation:` declarations, before the
2498    // unreferenced diff. Filtered to actually-defined keyframes so a stray
2499    // `animate-*` suffix never manufactures a false `undefined_keyframes`.
2500    for name in collect_markup_keyframe_references(files, config, ignore_set) {
2501        if tokens.defined_keyframes.contains(&name) {
2502            tokens.referenced_keyframes.insert(name);
2503        }
2504    }
2505
2506    let (unreferenced_keyframes, undefined_keyframes) = tokens.finalize(summary);
2507    let duplicate_declaration_blocks = tokens.group_duplicate_blocks(summary);
2508    let unused_at_rules = tokens.group_unused_at_rules(summary);
2509    let font_size_unit_mix = tokens.font_size_unit_mix(summary);
2510    let mut unused_font_faces = tokens.unused_font_faces(summary);
2511    // The CSS-only set difference cannot see a font family applied from
2512    // JavaScript / canvas (Excalidraw) or referenced from a `.scss`/`.sass`
2513    // theme the parser never reads (reveal.js). Drop any candidate whose family
2514    // name appears as a substring in ANY non-CSS source file, so only a font
2515    // declared and used nowhere at all survives. (Real-world smoke.)
2516    if !unused_font_faces.is_empty() {
2517        let referenced =
2518            font_families_referenced_in_source(&unused_font_faces, files, config, ignore_set);
2519        unused_font_faces.retain(|ff| !referenced.contains(&ff.family));
2520        summary.unused_font_faces = saturate_len(unused_font_faces.len());
2521    }
2522
2523    CssTokenMetrics {
2524        unreferenced_keyframes,
2525        undefined_keyframes,
2526        duplicate_declaration_blocks,
2527        unused_at_rules,
2528        font_size_unit_mix,
2529        unused_font_faces,
2530    }
2531}
2532
2533fn compute_css_analytics_report(
2534    files: &[fallow_types::discover::DiscoveredFile],
2535    ctx: HealthScanCtx<'_>,
2536) -> Option<fallow_output::CssAnalyticsReport> {
2537    let HealthScanCtx {
2538        config,
2539        ignore_set,
2540        changed_files,
2541        ws_roots,
2542    } = ctx;
2543
2544    let mut walk = walk_css_files(files, ctx);
2545    let metrics = finalize_css_token_metrics(
2546        &mut walk.tokens,
2547        &mut walk.summary,
2548        files,
2549        config,
2550        ignore_set,
2551    );
2552    let candidates = scan_markup_css_candidates(&mut MarkupCssCandidateInput {
2553        tokens: &walk.tokens,
2554        files,
2555        config,
2556        ignore_set,
2557        changed_files,
2558        ws_roots,
2559        summary: &mut walk.summary,
2560    });
2561    assemble_css_report(walk, metrics, candidates)
2562}
2563
2564/// Assemble the final CSS analytics report from the walk accumulator, finalized
2565/// token metrics, and markup candidates; returns `None` when nothing notable was
2566/// found (no analyzed files and every candidate list empty).
2567fn assemble_css_report(
2568    walk: CssWalkAccum,
2569    metrics: CssTokenMetrics,
2570    candidates: MarkupCssCandidates,
2571) -> Option<fallow_output::CssAnalyticsReport> {
2572    use fallow_output::CssAnalyticsReport;
2573
2574    let candidates_empty = candidates.tailwind_arbitrary_values.is_empty()
2575        && candidates.unresolved_class_references.is_empty()
2576        && candidates.unreferenced_css_classes.is_empty()
2577        && metrics.unused_font_faces.is_empty()
2578        && candidates.unused_theme_tokens.is_empty();
2579    if walk.summary.files_analyzed == 0 && walk.scoped_unused.is_empty() && candidates_empty {
2580        return None;
2581    }
2582    let mut scoped_unused = walk.scoped_unused;
2583    scoped_unused.sort_by(|a, b| a.path.cmp(&b.path));
2584    Some(CssAnalyticsReport {
2585        files: walk.file_reports,
2586        summary: walk.summary,
2587        scoped_unused,
2588        unreferenced_keyframes: metrics.unreferenced_keyframes,
2589        undefined_keyframes: metrics.undefined_keyframes,
2590        duplicate_declaration_blocks: metrics.duplicate_declaration_blocks,
2591        tailwind_arbitrary_values: candidates.tailwind_arbitrary_values,
2592        unused_at_rules: metrics.unused_at_rules,
2593        unresolved_class_references: candidates.unresolved_class_references,
2594        unreferenced_css_classes: candidates.unreferenced_css_classes,
2595        unused_font_faces: metrics.unused_font_faces,
2596        unused_theme_tokens: candidates.unused_theme_tokens,
2597        font_size_unit_mix: metrics.font_size_unit_mix,
2598    })
2599}
2600
2601struct HealthCoverageSettings {
2602    report_coverage_gaps: bool,
2603    enforce_coverage_gaps: bool,
2604    istanbul_coverage: Option<scoring::IstanbulCoverage>,
2605}
2606
2607struct HealthFindingsData {
2608    findings: Vec<ComplexityViolation>,
2609    threshold_overrides: Vec<fallow_output::ThresholdOverrideState>,
2610    files_analyzed: usize,
2611    total_functions: usize,
2612    complexity_ms: f64,
2613    total_above_threshold: usize,
2614    sev_critical: usize,
2615    sev_high: usize,
2616    sev_moderate: usize,
2617    loaded_baseline: Option<HealthBaselineData>,
2618}
2619
2620struct CollectedHealthFindings {
2621    findings: Vec<ComplexityViolation>,
2622    files_analyzed: usize,
2623    total_functions: usize,
2624    complexity_ms: f64,
2625}
2626
2627struct HealthOutputContextInput<'a, R> {
2628    config: &'a ResolvedConfig,
2629    files: &'a [fallow_types::discover::DiscoveredFile],
2630    modules: &'a [crate::extract::ModuleInfo],
2631    scope: &'a HealthScope<'a, R>,
2632    needs_file_scores: bool,
2633    report_coverage_gaps: bool,
2634    has_istanbul_coverage: bool,
2635    findings_data: HealthFindingsData,
2636    analysis_data: HealthAnalysisData,
2637    derived_sections: HealthDerivedSections,
2638    vital_data: HealthVitalData,
2639    timings: HealthPipelineTimings,
2640    start: &'a Instant,
2641}
2642
2643struct HealthOutputContext<'a, R> {
2644    build: HealthOutputBuildInput<'a, R>,
2645    sections: HealthOutputSectionInput,
2646}
2647
2648struct HealthOutputBuildInput<'a, R> {
2649    config: &'a ResolvedConfig,
2650    files: &'a [fallow_types::discover::DiscoveredFile],
2651    modules: &'a [crate::extract::ModuleInfo],
2652    file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
2653    group_resolver: Option<&'a R>,
2654    needs_file_scores: bool,
2655    report_coverage_gaps: bool,
2656    has_istanbul_coverage: bool,
2657    threshold_overrides: Vec<fallow_output::ThresholdOverrideState>,
2658    max_cyclomatic: u16,
2659    max_cognitive: u16,
2660    max_crap: f64,
2661    files_analyzed: usize,
2662    total_functions: usize,
2663    total_above_threshold: usize,
2664    sev_critical: usize,
2665    sev_high: usize,
2666    sev_moderate: usize,
2667    timing_base: HealthTimingBaseInput,
2668    start: &'a Instant,
2669}
2670
2671struct HealthOutputSectionInput {
2672    analysis_data: HealthAnalysisData,
2673    derived_sections: HealthDerivedSections,
2674    vital_data: HealthVitalData,
2675    findings: Vec<ComplexityViolation>,
2676}
2677
2678struct HealthOutputParts {
2679    report: fallow_output::HealthReport,
2680    grouping: Option<fallow_output::HealthGrouping>,
2681    timings: Option<fallow_output::HealthTimings>,
2682    coverage_gaps_has_findings: bool,
2683}
2684
2685struct HealthOutputSupportingParts {
2686    grouping: Option<fallow_output::HealthGrouping>,
2687    timings: Option<fallow_output::HealthTimings>,
2688}
2689
2690fn prepare_health_output_context<R>(
2691    input: HealthOutputContextInput<'_, R>,
2692) -> HealthOutputContext<'_, R> {
2693    let HealthFindingsData {
2694        findings,
2695        threshold_overrides,
2696        files_analyzed,
2697        total_functions,
2698        complexity_ms,
2699        total_above_threshold,
2700        sev_critical,
2701        sev_high,
2702        sev_moderate,
2703        loaded_baseline: _,
2704    } = input.findings_data;
2705
2706    HealthOutputContext {
2707        build: HealthOutputBuildInput {
2708            config: input.config,
2709            files: input.files,
2710            modules: input.modules,
2711            file_paths: &input.scope.file_paths,
2712            group_resolver: input.scope.group_resolver.as_ref(),
2713            needs_file_scores: input.needs_file_scores,
2714            report_coverage_gaps: input.report_coverage_gaps,
2715            has_istanbul_coverage: input.has_istanbul_coverage,
2716            threshold_overrides,
2717            max_cyclomatic: input.scope.max_cyclomatic,
2718            max_cognitive: input.scope.max_cognitive,
2719            max_crap: input.scope.max_crap,
2720            files_analyzed,
2721            total_functions,
2722            total_above_threshold,
2723            sev_critical,
2724            sev_high,
2725            sev_moderate,
2726            timing_base: input.timings.into_base_input(complexity_ms),
2727            start: input.start,
2728        },
2729        sections: HealthOutputSectionInput {
2730            analysis_data: input.analysis_data,
2731            derived_sections: input.derived_sections,
2732            vital_data: input.vital_data,
2733            findings,
2734        },
2735    }
2736}
2737
2738fn build_health_output_parts<R: super::HealthGroupResolver>(
2739    opts: &HealthOptions<'_>,
2740    build: &HealthOutputBuildInput<'_, R>,
2741    sections: HealthOutputSectionInput,
2742) -> HealthOutputParts {
2743    let HealthOutputSectionInput {
2744        analysis_data,
2745        derived_sections,
2746        vital_data,
2747        findings,
2748    } = sections;
2749    let coverage_gaps_has_findings =
2750        health_coverage_gaps_has_findings(analysis_data.score_output.as_ref());
2751    let action_ctx = build_health_action_context(
2752        opts,
2753        build.config,
2754        build.max_cyclomatic,
2755        build.max_cognitive,
2756        build.max_crap,
2757    );
2758
2759    let HealthOutputSupportingParts { grouping, timings } =
2760        build_health_supporting_parts(HealthSupportingPartsInput {
2761            opts,
2762            build,
2763            analysis_data: &analysis_data,
2764            derived_sections: &derived_sections,
2765            vital_data: &vital_data,
2766            findings: &findings,
2767            action_ctx: &action_ctx,
2768        });
2769
2770    let framework_health =
2771        build_framework_health_diagnostics(build.config, analysis_data.framework_health_facts);
2772
2773    let report = build_health_report_from_pipeline(
2774        opts,
2775        &action_ctx,
2776        build_health_report_pipeline_input(
2777            build,
2778            analysis_data,
2779            vital_data,
2780            derived_sections,
2781            findings,
2782            framework_health,
2783        ),
2784    );
2785
2786    HealthOutputParts {
2787        report,
2788        grouping,
2789        timings,
2790        coverage_gaps_has_findings,
2791    }
2792}
2793
2794fn build_health_report_pipeline_input<R>(
2795    build: &HealthOutputBuildInput<'_, R>,
2796    analysis_data: HealthAnalysisData,
2797    vital_data: HealthVitalData,
2798    derived_sections: HealthDerivedSections,
2799    findings: Vec<ComplexityViolation>,
2800    framework_health: Option<fallow_output::FrameworkHealthDiagnostics>,
2801) -> HealthReportPipelineInput {
2802    HealthReportPipelineInput {
2803        report_coverage_gaps: build.report_coverage_gaps,
2804        findings,
2805        threshold_overrides: build.threshold_overrides.clone(),
2806        files_analyzed: build.files_analyzed,
2807        total_functions: build.total_functions,
2808        total_above_threshold: build.total_above_threshold,
2809        max_cyclomatic: build.max_cyclomatic,
2810        max_cognitive: build.max_cognitive,
2811        max_crap: build.max_crap,
2812        analysis_data,
2813        vital_data,
2814        hotspots: derived_sections.hotspots,
2815        hotspot_summary: derived_sections.hotspot_summary,
2816        targets: derived_sections.targets,
2817        target_thresholds: derived_sections.target_thresholds,
2818        has_istanbul_coverage: build.has_istanbul_coverage,
2819        framework_health,
2820        sev_critical: build.sev_critical,
2821        sev_high: build.sev_high,
2822        sev_moderate: build.sev_moderate,
2823    }
2824}
2825
2826#[derive(Clone, Copy)]
2827struct HealthSupportingPartsInput<'a, R> {
2828    opts: &'a HealthOptions<'a>,
2829    build: &'a HealthOutputBuildInput<'a, R>,
2830    analysis_data: &'a HealthAnalysisData,
2831    derived_sections: &'a HealthDerivedSections,
2832    vital_data: &'a HealthVitalData,
2833    findings: &'a [ComplexityViolation],
2834    action_ctx: &'a fallow_output::HealthActionContext,
2835}
2836
2837#[expect(
2838    clippy::needless_pass_by_value,
2839    reason = "input is a Copy struct; by-value matches the original CLI signature"
2840)]
2841fn build_health_supporting_parts<R: super::HealthGroupResolver>(
2842    input: HealthSupportingPartsInput<'_, R>,
2843) -> HealthOutputSupportingParts {
2844    let grouping = build_health_output_grouping(&input);
2845    let timings = build_health_timings_from_pipeline(
2846        input.opts,
2847        input.build.start,
2848        input.analysis_data,
2849        input.derived_sections,
2850        &input.build.timing_base,
2851    );
2852
2853    HealthOutputSupportingParts { grouping, timings }
2854}
2855
2856fn build_health_output_grouping<R: super::HealthGroupResolver>(
2857    input: &HealthSupportingPartsInput<'_, R>,
2858) -> Option<fallow_output::HealthGrouping> {
2859    let file_scores = health_file_scores_slice(input.analysis_data.score_output.as_ref());
2860    build_health_grouping_from_context(HealthGroupingContextInput {
2861        opts: input.opts,
2862        config: input.build.config,
2863        group_resolver: input.build.group_resolver,
2864        candidate_paths: &input.derived_sections.candidate_paths,
2865        files: input.build.files,
2866        modules: input.build.modules,
2867        file_paths: input.build.file_paths,
2868        score_output: input.analysis_data.score_output.as_ref(),
2869        file_scores,
2870        findings: input.findings,
2871        hotspots: &input.derived_sections.hotspots,
2872        vital_data: input.vital_data,
2873        targets: &input.derived_sections.targets,
2874        needs_file_scores: input.build.needs_file_scores,
2875        action_ctx: input.action_ctx,
2876    })
2877}
2878
2879struct HealthDerivedSectionInput<'a> {
2880    config: &'a ResolvedConfig,
2881    files: &'a [fallow_types::discover::DiscoveredFile],
2882    ignore_set: &'a globset::GlobSet,
2883    changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
2884    ws_roots: Option<&'a [std::path::PathBuf]>,
2885    file_scores: &'a [FileHealthScore],
2886    churn_fetch: Option<hotspots::ChurnFetchResult>,
2887    diff_index: Option<&'a fallow_output::DiffIndex>,
2888    score_output: Option<&'a scoring::FileScoreOutput>,
2889    loaded_baseline: Option<&'a HealthBaselineData>,
2890}
2891
2892struct HealthDerivedSections {
2893    candidate_paths: rustc_hash::FxHashSet<std::path::PathBuf>,
2894    dupes_report: Option<crate::duplicates::DuplicationReport>,
2895    duplication_ms: f64,
2896    hotspots: Vec<HotspotEntry>,
2897    hotspot_summary: Option<HotspotSummary>,
2898    hotspots_ms: f64,
2899    targets: Vec<RefactoringTarget>,
2900    target_thresholds: Option<fallow_output::TargetThresholds>,
2901    targets_ms: f64,
2902}
2903
2904struct HealthReportPipelineInput {
2905    report_coverage_gaps: bool,
2906    findings: Vec<ComplexityViolation>,
2907    threshold_overrides: Vec<fallow_output::ThresholdOverrideState>,
2908    files_analyzed: usize,
2909    total_functions: usize,
2910    total_above_threshold: usize,
2911    max_cyclomatic: u16,
2912    max_cognitive: u16,
2913    max_crap: f64,
2914    analysis_data: HealthAnalysisData,
2915    vital_data: HealthVitalData,
2916    hotspots: Vec<HotspotEntry>,
2917    hotspot_summary: Option<HotspotSummary>,
2918    targets: Vec<RefactoringTarget>,
2919    target_thresholds: Option<fallow_output::TargetThresholds>,
2920    has_istanbul_coverage: bool,
2921    framework_health: Option<fallow_output::FrameworkHealthDiagnostics>,
2922    sev_critical: usize,
2923    sev_high: usize,
2924    sev_moderate: usize,
2925}
2926
2927fn build_health_report_from_pipeline(
2928    opts: &HealthOptions<'_>,
2929    action_ctx: &fallow_output::HealthActionContext,
2930    input: HealthReportPipelineInput,
2931) -> fallow_output::HealthReport {
2932    assemble_health_report(
2933        opts,
2934        action_ctx,
2935        HealthReportAssembly {
2936            report_coverage_gaps: input.report_coverage_gaps,
2937            findings: input.findings,
2938            threshold_overrides: input.threshold_overrides,
2939            files_analyzed: input.files_analyzed,
2940            total_functions: input.total_functions,
2941            total_above_threshold: input.total_above_threshold,
2942            max_cyclomatic: input.max_cyclomatic,
2943            max_cognitive: input.max_cognitive,
2944            max_crap: input.max_crap,
2945            files_scored: input.analysis_data.files_scored,
2946            average_maintainability: input.analysis_data.average_maintainability,
2947            vital_signs: input.vital_data.vital_signs,
2948            health_score: input.vital_data.health_score,
2949            score_output: input.analysis_data.score_output,
2950            hotspots: input.hotspots,
2951            hotspot_summary: input.hotspot_summary,
2952            targets: input.targets,
2953            target_thresholds: input.target_thresholds,
2954            health_trend: input.vital_data.health_trend,
2955            has_istanbul_coverage: input.has_istanbul_coverage,
2956            runtime_coverage: input.analysis_data.runtime_coverage,
2957            framework_health: input.framework_health,
2958            large_functions: input.vital_data.large_functions,
2959            sev_critical: input.sev_critical,
2960            sev_high: input.sev_high,
2961            sev_moderate: input.sev_moderate,
2962        },
2963    )
2964}
2965
2966#[derive(Debug, Clone, Copy)]
2967struct GlobalHealthThresholds {
2968    cyclomatic: u16,
2969    cognitive: u16,
2970    crap: f64,
2971}
2972
2973#[derive(Debug, Clone, Copy)]
2974struct AppliedHealthThresholds {
2975    effective: fallow_output::HealthEffectiveThresholds,
2976    override_index: Option<usize>,
2977}
2978
2979struct CompiledThresholdOverride {
2980    index: usize,
2981    matchers: globset::GlobSet,
2982    functions: Vec<String>,
2983    configured: fallow_output::HealthConfiguredThresholds,
2984    reason: Option<String>,
2985}
2986
2987struct ThresholdOverrideMatch<'a> {
2988    entry: &'a CompiledThresholdOverride,
2989    effective: fallow_output::HealthEffectiveThresholds,
2990}
2991
2992struct ThresholdOverrideResolver {
2993    entries: Vec<CompiledThresholdOverride>,
2994    global: GlobalHealthThresholds,
2995}
2996
2997impl ThresholdOverrideResolver {
2998    #[must_use]
2999    fn new(
3000        overrides: &[fallow_config::HealthThresholdOverride],
3001        global: GlobalHealthThresholds,
3002    ) -> Self {
3003        let entries = overrides
3004            .iter()
3005            .enumerate()
3006            .map(|(index, override_entry)| {
3007                let mut builder = globset::GlobSetBuilder::new();
3008                for pattern in &override_entry.files {
3009                    if let Ok(glob) = globset::Glob::new(pattern) {
3010                        builder.add(glob);
3011                    }
3012                }
3013                CompiledThresholdOverride {
3014                    index,
3015                    matchers: builder
3016                        .build()
3017                        .unwrap_or_else(|_| globset::GlobSet::empty()),
3018                    functions: override_entry.functions.clone(),
3019                    configured: fallow_output::HealthConfiguredThresholds {
3020                        max_cyclomatic: override_entry.max_cyclomatic,
3021                        max_cognitive: override_entry.max_cognitive,
3022                        max_crap: override_entry.max_crap,
3023                    },
3024                    reason: override_entry.reason.clone(),
3025                }
3026            })
3027            .collect();
3028        Self { entries, global }
3029    }
3030
3031    #[must_use]
3032    fn resolve(
3033        &self,
3034        relative: &std::path::Path,
3035        function: &str,
3036    ) -> (AppliedHealthThresholds, Vec<ThresholdOverrideMatch<'_>>) {
3037        let mut effective = fallow_output::HealthEffectiveThresholds {
3038            max_cyclomatic: self.global.cyclomatic,
3039            max_cognitive: self.global.cognitive,
3040            max_crap: self.global.crap,
3041        };
3042        let mut override_index = None;
3043        let mut matches = Vec::new();
3044
3045        for entry in &self.entries {
3046            if !entry.matchers.is_match(relative) {
3047                continue;
3048            }
3049            if !entry.functions.is_empty() && !entry.functions.iter().any(|f| f == function) {
3050                continue;
3051            }
3052            if let Some(max_cyclomatic) = entry.configured.max_cyclomatic {
3053                effective.max_cyclomatic = max_cyclomatic;
3054                override_index = Some(entry.index);
3055            }
3056            if let Some(max_cognitive) = entry.configured.max_cognitive {
3057                effective.max_cognitive = max_cognitive;
3058                override_index = Some(entry.index);
3059            }
3060            if let Some(max_crap) = entry.configured.max_crap {
3061                effective.max_crap = max_crap;
3062                override_index = Some(entry.index);
3063            }
3064            matches.push(ThresholdOverrideMatch { entry, effective });
3065        }
3066
3067        (
3068            AppliedHealthThresholds {
3069                effective,
3070                override_index,
3071            },
3072            matches,
3073        )
3074    }
3075
3076    fn entries(&self) -> &[CompiledThresholdOverride] {
3077        &self.entries
3078    }
3079}
3080
3081#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
3082enum ThresholdOverrideDimension {
3083    Complexity,
3084    Crap,
3085}
3086
3087#[derive(Debug, Clone, PartialEq, Eq, Hash)]
3088struct ThresholdOverrideStateKey {
3089    status: &'static str,
3090    override_index: usize,
3091    path: Option<std::path::PathBuf>,
3092    function: Option<String>,
3093    dimension: ThresholdOverrideDimension,
3094}
3095
3096#[derive(Debug, Clone, Copy)]
3097struct MeasuredThresholdMetrics {
3098    cyclomatic: u16,
3099    cognitive: u16,
3100    crap: f64,
3101}
3102
3103#[derive(Default)]
3104struct ThresholdOverrideStateTracker {
3105    matched_indexes: rustc_hash::FxHashSet<usize>,
3106    seen: rustc_hash::FxHashSet<ThresholdOverrideStateKey>,
3107    states: Vec<fallow_output::ThresholdOverrideState>,
3108}
3109
3110impl ThresholdOverrideStateTracker {
3111    fn record_complexity(
3112        &mut self,
3113        function: ComplexityFunctionContext<'_>,
3114        matches: &[ThresholdOverrideMatch<'_>],
3115        global: GlobalHealthThresholds,
3116    ) {
3117        let ComplexityFunctionContext {
3118            path,
3119            function,
3120            cyclomatic,
3121            cognitive,
3122        } = function;
3123        for matched in matches {
3124            self.matched_indexes.insert(matched.entry.index);
3125            let configured = matched.entry.configured;
3126            let has_complexity_threshold =
3127                configured.max_cyclomatic.is_some() || configured.max_cognitive.is_some();
3128            if !has_complexity_threshold {
3129                continue;
3130            }
3131            let global_exceeded = configured
3132                .max_cyclomatic
3133                .is_some_and(|_| cyclomatic > global.cyclomatic)
3134                || configured
3135                    .max_cognitive
3136                    .is_some_and(|_| cognitive > global.cognitive);
3137            let local_exceeded = configured
3138                .max_cyclomatic
3139                .is_some_and(|threshold| cyclomatic > threshold)
3140                || configured
3141                    .max_cognitive
3142                    .is_some_and(|threshold| cognitive > threshold);
3143            let status = if global_exceeded && !local_exceeded {
3144                fallow_output::ThresholdOverrideStatus::Active
3145            } else if !global_exceeded {
3146                fallow_output::ThresholdOverrideStatus::Stale
3147            } else {
3148                continue;
3149            };
3150            self.push_state(ThresholdOverrideStateInput {
3151                status,
3152                override_index: matched.entry.index,
3153                path: Some(path.to_path_buf()),
3154                function: Some(function.to_string()),
3155                configured_thresholds: configured,
3156                effective_thresholds: matched.effective,
3157                metrics: Some(fallow_output::ThresholdOverrideMetrics {
3158                    cyclomatic,
3159                    cognitive,
3160                    crap: None,
3161                }),
3162                reason: matched.entry.reason.clone(),
3163                dimension: ThresholdOverrideDimension::Complexity,
3164            });
3165        }
3166    }
3167
3168    fn record_crap(
3169        &mut self,
3170        path: &std::path::Path,
3171        function: &str,
3172        metrics: MeasuredThresholdMetrics,
3173        matches: &[ThresholdOverrideMatch<'_>],
3174        global: GlobalHealthThresholds,
3175    ) {
3176        for matched in matches {
3177            self.matched_indexes.insert(matched.entry.index);
3178            let Some(max_crap) = matched.entry.configured.max_crap else {
3179                continue;
3180            };
3181            let status = if metrics.crap >= global.crap && metrics.crap < max_crap {
3182                fallow_output::ThresholdOverrideStatus::Active
3183            } else if metrics.crap < global.crap {
3184                fallow_output::ThresholdOverrideStatus::Stale
3185            } else {
3186                continue;
3187            };
3188            self.push_state(ThresholdOverrideStateInput {
3189                status,
3190                override_index: matched.entry.index,
3191                path: Some(path.to_path_buf()),
3192                function: Some(function.to_string()),
3193                configured_thresholds: matched.entry.configured,
3194                effective_thresholds: matched.effective,
3195                metrics: Some(fallow_output::ThresholdOverrideMetrics {
3196                    cyclomatic: metrics.cyclomatic,
3197                    cognitive: metrics.cognitive,
3198                    crap: Some(metrics.crap),
3199                }),
3200                reason: matched.entry.reason.clone(),
3201                dimension: ThresholdOverrideDimension::Crap,
3202            });
3203        }
3204    }
3205
3206    fn record_no_match_entries(&mut self, resolver: &ThresholdOverrideResolver, should_emit: bool) {
3207        if !should_emit {
3208            return;
3209        }
3210        for entry in resolver.entries() {
3211            if self.matched_indexes.contains(&entry.index) {
3212                continue;
3213            }
3214            self.push_state(ThresholdOverrideStateInput {
3215                status: fallow_output::ThresholdOverrideStatus::NoMatch,
3216                override_index: entry.index,
3217                path: None,
3218                function: None,
3219                configured_thresholds: entry.configured,
3220                effective_thresholds: fallow_output::HealthEffectiveThresholds {
3221                    max_cyclomatic: entry
3222                        .configured
3223                        .max_cyclomatic
3224                        .unwrap_or(resolver.global.cyclomatic),
3225                    max_cognitive: entry
3226                        .configured
3227                        .max_cognitive
3228                        .unwrap_or(resolver.global.cognitive),
3229                    max_crap: entry.configured.max_crap.unwrap_or(resolver.global.crap),
3230                },
3231                metrics: None,
3232                reason: entry.reason.clone(),
3233                dimension: ThresholdOverrideDimension::Complexity,
3234            });
3235        }
3236    }
3237
3238    fn into_states(mut self) -> Vec<fallow_output::ThresholdOverrideState> {
3239        self.states.sort_by(|a, b| {
3240            a.override_index
3241                .cmp(&b.override_index)
3242                .then(a.path.cmp(&b.path))
3243                .then(a.function.cmp(&b.function))
3244        });
3245        self.states
3246    }
3247
3248    fn push_state(&mut self, input: ThresholdOverrideStateInput) {
3249        let status_key = match input.status {
3250            fallow_output::ThresholdOverrideStatus::Active => "active",
3251            fallow_output::ThresholdOverrideStatus::Stale => "stale",
3252            fallow_output::ThresholdOverrideStatus::NoMatch => "no_match",
3253        };
3254        let key = ThresholdOverrideStateKey {
3255            status: status_key,
3256            override_index: input.override_index,
3257            path: input.path.clone(),
3258            function: input.function.clone(),
3259            dimension: input.dimension,
3260        };
3261        if !self.seen.insert(key) {
3262            return;
3263        }
3264        self.states.push(fallow_output::ThresholdOverrideState {
3265            status: input.status,
3266            override_index: input.override_index,
3267            path: input.path,
3268            function: input.function,
3269            configured_thresholds: input.configured_thresholds,
3270            effective_thresholds: input.effective_thresholds,
3271            metrics: input.metrics,
3272            reason: input.reason,
3273        });
3274    }
3275}
3276
3277/// One function's identity (path + name) and measured complexity metrics,
3278/// bundled so `record_complexity` takes the function descriptor as a single
3279/// parameter instead of four.
3280#[derive(Clone, Copy)]
3281struct ComplexityFunctionContext<'a> {
3282    path: &'a std::path::Path,
3283    function: &'a str,
3284    cyclomatic: u16,
3285    cognitive: u16,
3286}
3287
3288struct ThresholdOverrideStateInput {
3289    status: fallow_output::ThresholdOverrideStatus,
3290    override_index: usize,
3291    path: Option<std::path::PathBuf>,
3292    function: Option<String>,
3293    configured_thresholds: fallow_output::HealthConfiguredThresholds,
3294    effective_thresholds: fallow_output::HealthEffectiveThresholds,
3295    metrics: Option<fallow_output::ThresholdOverrideMetrics>,
3296    reason: Option<String>,
3297    dimension: ThresholdOverrideDimension,
3298}
3299
3300#[derive(Clone, Copy)]
3301struct HealthGroupingContextInput<'a, R> {
3302    opts: &'a HealthOptions<'a>,
3303    config: &'a ResolvedConfig,
3304    group_resolver: Option<&'a R>,
3305    candidate_paths: &'a rustc_hash::FxHashSet<std::path::PathBuf>,
3306    files: &'a [fallow_types::discover::DiscoveredFile],
3307    modules: &'a [crate::extract::ModuleInfo],
3308    file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
3309    score_output: Option<&'a scoring::FileScoreOutput>,
3310    file_scores: &'a [FileHealthScore],
3311    findings: &'a [ComplexityViolation],
3312    hotspots: &'a [HotspotEntry],
3313    vital_data: &'a HealthVitalData,
3314    targets: &'a [RefactoringTarget],
3315    needs_file_scores: bool,
3316    action_ctx: &'a fallow_output::HealthActionContext,
3317}
3318
3319#[expect(
3320    clippy::needless_pass_by_value,
3321    reason = "input is a Copy struct; by-value matches the original CLI signature"
3322)]
3323fn build_health_grouping_from_context<R: super::HealthGroupResolver>(
3324    input: HealthGroupingContextInput<'_, R>,
3325) -> Option<fallow_output::HealthGrouping> {
3326    build_optional_health_grouping_opt(
3327        input.group_resolver,
3328        &input.config.root,
3329        input.candidate_paths,
3330        &grouping::HealthGroupingInput {
3331            files: input.files,
3332            modules: input.modules,
3333            file_paths: input.file_paths,
3334            score_output: input.score_output,
3335            file_scores: input.file_scores,
3336            findings: input.findings,
3337            hotspots: input.hotspots,
3338            large_functions: &input.vital_data.large_functions,
3339            targets: input.targets,
3340            score_requested: input.opts.score,
3341            duplicates_config: input.opts.score.then_some(&input.config.duplicates),
3342            needs_file_scores: input.needs_file_scores,
3343            needs_hotspots: input.opts.hotspots || input.opts.targets,
3344            show_vital_signs: !input.opts.score_only_output,
3345            action_ctx: input.action_ctx,
3346        },
3347    )
3348}
3349
3350fn needs_health_file_scores(
3351    opts: &HealthOptions<'_>,
3352    report_coverage_gaps: bool,
3353    enforce_coverage_gaps: bool,
3354    enforce_crap: bool,
3355) -> bool {
3356    opts.file_scores
3357        || report_coverage_gaps
3358        || enforce_coverage_gaps
3359        || opts.hotspots
3360        || opts.targets
3361        || opts.force_full
3362        || enforce_crap
3363}
3364
3365fn health_coverage_gaps_has_findings(score_output: Option<&scoring::FileScoreOutput>) -> bool {
3366    score_output.is_some_and(|output| !output.coverage.report.is_empty())
3367}
3368
3369fn health_file_scores_slice(score_output: Option<&scoring::FileScoreOutput>) -> &[FileHealthScore] {
3370    score_output.map_or(&[] as &[_], |output| output.scores.as_slice())
3371}
3372
3373fn prepare_health_derived_sections(
3374    opts: &HealthOptions<'_>,
3375    input: HealthDerivedSectionInput<'_>,
3376) -> HealthDerivedSections {
3377    let (candidate_paths, dupes_report, duplication_ms) =
3378        prepare_health_section_dupes(opts, &input);
3379    let (hotspots, hotspot_summary, hotspots_ms) = prepare_health_section_hotspots(
3380        opts,
3381        HealthHotspotSectionInput {
3382            config: input.config,
3383            file_scores: input.file_scores,
3384            ignore_set: input.ignore_set,
3385            ws_roots: input.ws_roots,
3386            churn_fetch: input.churn_fetch,
3387            diff_index: input.diff_index,
3388        },
3389    );
3390    let (targets, target_thresholds, targets_ms) = prepare_health_section_targets(
3391        opts,
3392        &HealthTargetSectionInput {
3393            score_output: input.score_output,
3394            file_scores: input.file_scores,
3395            hotspots: &hotspots,
3396            loaded_baseline: input.loaded_baseline,
3397            config: input.config,
3398            diff_index: input.diff_index,
3399            dupes_report: dupes_report.as_ref(),
3400        },
3401    );
3402
3403    HealthDerivedSections {
3404        candidate_paths,
3405        dupes_report,
3406        duplication_ms,
3407        hotspots,
3408        hotspot_summary,
3409        hotspots_ms,
3410        targets,
3411        target_thresholds,
3412        targets_ms,
3413    }
3414}
3415
3416fn prepare_health_section_dupes(
3417    opts: &HealthOptions<'_>,
3418    input: &HealthDerivedSectionInput<'_>,
3419) -> (
3420    rustc_hash::FxHashSet<std::path::PathBuf>,
3421    Option<crate::duplicates::DuplicationReport>,
3422    f64,
3423) {
3424    prepare_health_duplication_data(
3425        opts,
3426        input.config,
3427        input.files,
3428        input.changed_files,
3429        input.ws_roots,
3430        input.ignore_set,
3431    )
3432}
3433
3434struct HealthHotspotSectionInput<'a> {
3435    config: &'a ResolvedConfig,
3436    file_scores: &'a [FileHealthScore],
3437    ignore_set: &'a globset::GlobSet,
3438    ws_roots: Option<&'a [std::path::PathBuf]>,
3439    churn_fetch: Option<hotspots::ChurnFetchResult>,
3440    diff_index: Option<&'a fallow_output::DiffIndex>,
3441}
3442
3443fn prepare_health_section_hotspots(
3444    opts: &HealthOptions<'_>,
3445    input: HealthHotspotSectionInput<'_>,
3446) -> (Vec<HotspotEntry>, Option<HotspotSummary>, f64) {
3447    compute_filtered_hotspots(FilteredHotspotInput {
3448        opts,
3449        config: input.config,
3450        file_scores_slice: input.file_scores,
3451        ignore_set: input.ignore_set,
3452        ws_roots: input.ws_roots,
3453        churn_fetch: input.churn_fetch,
3454        diff_index: input.diff_index,
3455    })
3456}
3457
3458struct HealthTargetSectionInput<'a> {
3459    score_output: Option<&'a scoring::FileScoreOutput>,
3460    file_scores: &'a [FileHealthScore],
3461    hotspots: &'a [HotspotEntry],
3462    loaded_baseline: Option<&'a HealthBaselineData>,
3463    config: &'a ResolvedConfig,
3464    diff_index: Option<&'a fallow_output::DiffIndex>,
3465    dupes_report: Option<&'a crate::duplicates::DuplicationReport>,
3466}
3467
3468fn prepare_health_section_targets(
3469    opts: &HealthOptions<'_>,
3470    input: &HealthTargetSectionInput<'_>,
3471) -> (Vec<RefactoringTarget>, Option<TargetThresholds>, f64) {
3472    compute_filtered_targets(FilteredTargetInput {
3473        opts,
3474        score_output: input.score_output,
3475        file_scores_slice: input.file_scores,
3476        hotspots: input.hotspots,
3477        loaded_baseline: input.loaded_baseline,
3478        config: input.config,
3479        diff_index: input.diff_index,
3480        dupes_report: input.dupes_report,
3481    })
3482}
3483
3484struct HealthTimingInput {
3485    config_ms: f64,
3486    discover_ms: f64,
3487    parse_ms: f64,
3488    parse_cpu_ms: f64,
3489    complexity_ms: f64,
3490    file_scores_ms: f64,
3491    git_churn_ms: f64,
3492    git_churn_cache_hit: bool,
3493    hotspots_ms: f64,
3494    duplication_ms: f64,
3495    targets_ms: f64,
3496    shared_parse: bool,
3497}
3498
3499struct HealthTimingBaseInput {
3500    config_ms: f64,
3501    discover_ms: f64,
3502    parse_ms: f64,
3503    parse_cpu_ms: f64,
3504    complexity_ms: f64,
3505    shared_parse: bool,
3506}
3507
3508struct HealthResultInput<R> {
3509    config: ResolvedConfig,
3510    report: fallow_output::HealthReport,
3511    grouping: Option<fallow_output::HealthGrouping>,
3512    group_resolver: Option<R>,
3513    elapsed: Duration,
3514    timings: Option<fallow_output::HealthTimings>,
3515    coverage_gaps_has_findings: bool,
3516    should_fail_on_coverage_gaps: bool,
3517}
3518
3519fn build_health_result<R>(input: HealthResultInput<R>) -> HealthResultGeneric<R> {
3520    let HealthResultInput {
3521        config,
3522        report,
3523        grouping,
3524        group_resolver,
3525        elapsed,
3526        timings,
3527        coverage_gaps_has_findings,
3528        should_fail_on_coverage_gaps,
3529    } = input;
3530
3531    HealthResultGeneric {
3532        report,
3533        grouping,
3534        group_resolver,
3535        config,
3536        elapsed,
3537        timings,
3538        coverage_gaps_has_findings,
3539        should_fail_on_coverage_gaps,
3540    }
3541}
3542
3543#[derive(Clone, Copy)]
3544struct HealthFindingsInput<'a> {
3545    opts: &'a HealthOptions<'a>,
3546    config: &'a ResolvedConfig,
3547    modules: &'a [crate::extract::ModuleInfo],
3548    file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
3549    ignore_set: &'a globset::GlobSet,
3550    changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
3551    ws_roots: Option<&'a [std::path::PathBuf]>,
3552    diff_index: Option<&'a fallow_output::DiffIndex>,
3553    max_cyclomatic: u16,
3554    max_cognitive: u16,
3555    max_crap: f64,
3556    enforce_crap: bool,
3557    score_output: Option<&'a scoring::FileScoreOutput>,
3558}
3559
3560fn prepare_health_findings(input: HealthFindingsInput<'_>) -> Result<HealthFindingsData, ExitCode> {
3561    let t = Instant::now();
3562    let global_thresholds = GlobalHealthThresholds {
3563        cyclomatic: input.max_cyclomatic,
3564        cognitive: input.max_cognitive,
3565        crap: input.max_crap,
3566    };
3567    let threshold_resolver =
3568        ThresholdOverrideResolver::new(&input.config.health.threshold_overrides, global_thresholds);
3569    let mut threshold_state_tracker = ThresholdOverrideStateTracker::default();
3570    let mut collected =
3571        collect_health_findings(input, &threshold_resolver, &mut threshold_state_tracker, t);
3572
3573    let mut crap_ctx = HealthCrapMergeContext {
3574        modules: input.modules,
3575        file_paths: input.file_paths,
3576        ignore_set: input.ignore_set,
3577        changed_files: input.changed_files,
3578        ws_roots: input.ws_roots,
3579        max_cyclomatic: input.max_cyclomatic,
3580        max_cognitive: input.max_cognitive,
3581        enforce_crap: input.enforce_crap,
3582        score_output: input.score_output,
3583        config_root: &input.config.root,
3584        threshold_resolver: &threshold_resolver,
3585        threshold_state_tracker: &mut threshold_state_tracker,
3586    };
3587    apply_optional_crap_findings(input.opts, &mut collected.findings, &mut crap_ctx);
3588    let (total_above_threshold, sev_critical, sev_high, sev_moderate, loaded_baseline) =
3589        finalize_health_findings(
3590            input.opts,
3591            input.config,
3592            &mut collected.findings,
3593            input.diff_index,
3594        )?;
3595    threshold_state_tracker.record_no_match_entries(
3596        &threshold_resolver,
3597        should_emit_no_match_threshold_overrides(
3598            input.opts,
3599            input.changed_files,
3600            input.ws_roots,
3601            input.diff_index,
3602        ),
3603    );
3604
3605    Ok(HealthFindingsData {
3606        findings: collected.findings,
3607        threshold_overrides: threshold_state_tracker.into_states(),
3608        files_analyzed: collected.files_analyzed,
3609        total_functions: collected.total_functions,
3610        complexity_ms: collected.complexity_ms,
3611        total_above_threshold,
3612        sev_critical,
3613        sev_high,
3614        sev_moderate,
3615        loaded_baseline,
3616    })
3617}
3618
3619fn collect_health_findings(
3620    input: HealthFindingsInput<'_>,
3621    threshold_resolver: &ThresholdOverrideResolver,
3622    threshold_state_tracker: &mut ThresholdOverrideStateTracker,
3623    started_at: Instant,
3624) -> CollectedHealthFindings {
3625    let mut collect_input = CollectFindingsInput {
3626        modules: input.modules,
3627        file_paths: input.file_paths,
3628        config_root: &input.config.root,
3629        ignore_set: input.ignore_set,
3630        changed_files: input.changed_files,
3631        ws_roots: input.ws_roots,
3632        threshold_resolver,
3633        threshold_state_tracker,
3634        complexity_breakdown: input.opts.complexity_breakdown,
3635    };
3636    let (findings, files_analyzed, total_functions) =
3637        collect_findings_with_resolver(&mut collect_input);
3638
3639    CollectedHealthFindings {
3640        findings,
3641        files_analyzed,
3642        total_functions,
3643        complexity_ms: started_at.elapsed().as_secs_f64() * 1000.0,
3644    }
3645}
3646
3647struct HealthCrapMergeContext<'a> {
3648    modules: &'a [crate::extract::ModuleInfo],
3649    file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
3650    ignore_set: &'a globset::GlobSet,
3651    changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
3652    ws_roots: Option<&'a [std::path::PathBuf]>,
3653    max_cyclomatic: u16,
3654    max_cognitive: u16,
3655    enforce_crap: bool,
3656    score_output: Option<&'a scoring::FileScoreOutput>,
3657    config_root: &'a std::path::Path,
3658    threshold_resolver: &'a ThresholdOverrideResolver,
3659    threshold_state_tracker: &'a mut ThresholdOverrideStateTracker,
3660}
3661
3662fn apply_optional_crap_findings(
3663    opts: &HealthOptions<'_>,
3664    findings: &mut Vec<ComplexityViolation>,
3665    ctx: &mut HealthCrapMergeContext<'_>,
3666) {
3667    if ctx.enforce_crap
3668        && let Some(score_out) = ctx.score_output
3669    {
3670        let mut input = CrapFindingMergeInput {
3671            modules: ctx.modules,
3672            file_paths: ctx.file_paths,
3673            config_root: ctx.config_root,
3674            ignore_set: ctx.ignore_set,
3675            changed_files: ctx.changed_files,
3676            ws_roots: ctx.ws_roots,
3677            per_function_crap: &score_out.per_function_crap,
3678            template_inherit_provenance: &score_out.template_inherit_provenance,
3679            complexity_breakdown: opts.complexity_breakdown,
3680            threshold_resolver: ctx.threshold_resolver,
3681            threshold_state_tracker: ctx.threshold_state_tracker,
3682        };
3683        merge_crap_findings(findings, &mut input);
3684    }
3685    append_component_rollup_findings(
3686        findings,
3687        ctx.score_output
3688            .map(|output| &output.template_inherit_provenance),
3689        ctx.max_cyclomatic,
3690        ctx.max_cognitive,
3691    );
3692}
3693
3694fn should_emit_no_match_threshold_overrides(
3695    opts: &HealthOptions<'_>,
3696    changed_files: Option<&rustc_hash::FxHashSet<std::path::PathBuf>>,
3697    ws_roots: Option<&[std::path::PathBuf]>,
3698    diff_index: Option<&fallow_output::DiffIndex>,
3699) -> bool {
3700    opts.changed_since.is_none()
3701        && opts.diff_index.is_none()
3702        && !opts.use_shared_diff_index
3703        && opts.workspace.is_none()
3704        && opts.changed_workspaces.is_none()
3705        && changed_files.is_none()
3706        && ws_roots.is_none()
3707        && diff_index.is_none()
3708}
3709
3710type HealthFindingFinalizeResult = (usize, usize, usize, usize, Option<HealthBaselineData>);
3711
3712fn finalize_health_findings(
3713    opts: &HealthOptions<'_>,
3714    config: &ResolvedConfig,
3715    findings: &mut Vec<ComplexityViolation>,
3716    diff_index: Option<&fallow_output::DiffIndex>,
3717) -> Result<HealthFindingFinalizeResult, ExitCode> {
3718    if let Some(diff_index) = diff_index {
3719        filter_complexity_findings_by_diff(findings, diff_index, &config.root);
3720    }
3721    sort_findings(findings, opts.sort);
3722    let total_above_threshold = findings.len();
3723    let (sev_critical, sev_high, sev_moderate) = count_finding_severities(findings);
3724    let loaded_baseline = apply_health_baseline_and_top(opts, config, findings)?;
3725    Ok((
3726        total_above_threshold,
3727        sev_critical,
3728        sev_high,
3729        sev_moderate,
3730        loaded_baseline,
3731    ))
3732}
3733
3734fn build_health_timings_from_pipeline(
3735    opts: &HealthOptions<'_>,
3736    start: &Instant,
3737    analysis_data: &HealthAnalysisData,
3738    sections: &HealthDerivedSections,
3739    input: &HealthTimingBaseInput,
3740) -> Option<HealthTimings> {
3741    build_health_timings(
3742        opts,
3743        start,
3744        &HealthTimingInput {
3745            config_ms: input.config_ms,
3746            discover_ms: input.discover_ms,
3747            parse_ms: input.parse_ms,
3748            parse_cpu_ms: input.parse_cpu_ms,
3749            complexity_ms: input.complexity_ms,
3750            file_scores_ms: analysis_data.file_scores_ms,
3751            git_churn_ms: analysis_data.git_churn_ms,
3752            git_churn_cache_hit: analysis_data.git_churn_cache_hit,
3753            hotspots_ms: sections.hotspots_ms,
3754            duplication_ms: sections.duplication_ms,
3755            targets_ms: sections.targets_ms,
3756            shared_parse: input.shared_parse,
3757        },
3758    )
3759}
3760
3761fn build_health_timings(
3762    opts: &HealthOptions<'_>,
3763    start: &Instant,
3764    input: &HealthTimingInput,
3765) -> Option<HealthTimings> {
3766    if !opts.performance {
3767        return None;
3768    }
3769
3770    let inner_ms = start.elapsed().as_secs_f64() * 1000.0;
3771    let total_ms = input.config_ms + input.discover_ms + input.parse_ms + inner_ms;
3772    Some(HealthTimings {
3773        config_ms: input.config_ms,
3774        discover_ms: input.discover_ms,
3775        parse_ms: input.parse_ms,
3776        parse_cpu_ms: input.parse_cpu_ms,
3777        complexity_ms: input.complexity_ms,
3778        file_scores_ms: input.file_scores_ms,
3779        git_churn_ms: input.git_churn_ms,
3780        git_churn_cache_hit: input.git_churn_cache_hit,
3781        hotspots_ms: input.hotspots_ms,
3782        duplication_ms: input.duplication_ms,
3783        targets_ms: input.targets_ms,
3784        total_ms,
3785        shared_parse: input.shared_parse,
3786    })
3787}
3788
3789fn prepare_health_coverage_settings(
3790    opts: &HealthOptions<'_>,
3791    config: &ResolvedConfig,
3792) -> Result<HealthCoverageSettings, ExitCode> {
3793    let config_coverage_enabled = config.rules.coverage_gaps != fallow_config::Severity::Off;
3794    let report_coverage_gaps =
3795        opts.coverage_gaps || (opts.config_activates_coverage_gaps && config_coverage_enabled);
3796    let enforce_coverage_gaps = opts.enforce_coverage_gap_gate
3797        && config.rules.coverage_gaps == fallow_config::Severity::Error;
3798    let istanbul_coverage = load_health_coverage(opts, config)?;
3799
3800    Ok(HealthCoverageSettings {
3801        report_coverage_gaps,
3802        enforce_coverage_gaps,
3803        istanbul_coverage,
3804    })
3805}
3806
3807fn build_optional_health_grouping_opt<R: super::HealthGroupResolver>(
3808    resolver: Option<&R>,
3809    project_root: &std::path::Path,
3810    candidate_paths: &rustc_hash::FxHashSet<std::path::PathBuf>,
3811    input: &grouping::HealthGroupingInput<'_>,
3812) -> Option<HealthGrouping> {
3813    let resolver = resolver?;
3814    Some(grouping::build_health_grouping(
3815        resolver as &dyn super::HealthGroupResolver,
3816        project_root,
3817        candidate_paths,
3818        input,
3819    ))
3820}
3821
3822fn active_health_coverage_model(has_istanbul_coverage: bool) -> fallow_output::CoverageModel {
3823    if has_istanbul_coverage {
3824        fallow_output::CoverageModel::Istanbul
3825    } else {
3826        fallow_output::CoverageModel::StaticEstimated
3827    }
3828}
3829
3830fn build_health_action_context(
3831    opts: &HealthOptions<'_>,
3832    config: &ResolvedConfig,
3833    max_cyclomatic: u16,
3834    max_cognitive: u16,
3835    max_crap: f64,
3836) -> fallow_output::HealthActionContext {
3837    let baseline_active = opts.baseline.is_some() || opts.save_baseline.is_some();
3838    let action_opts = if baseline_active {
3839        fallow_output::HealthActionOptions {
3840            omit_suppress_line: true,
3841            omit_reason: Some("baseline-active"),
3842        }
3843    } else if !config.health.suggest_inline_suppression {
3844        fallow_output::HealthActionOptions {
3845            omit_suppress_line: true,
3846            omit_reason: Some("config-disabled"),
3847        }
3848    } else {
3849        fallow_output::HealthActionOptions::default()
3850    };
3851    fallow_output::HealthActionContext {
3852        opts: action_opts,
3853        max_cyclomatic_threshold: max_cyclomatic,
3854        max_cognitive_threshold: max_cognitive,
3855        max_crap_threshold: max_crap,
3856        crap_refactor_band: config.health.crap_refactor_band,
3857    }
3858}
3859
3860fn prepare_health_scope<'a, R>(
3861    opts: &HealthOptions<'a>,
3862    config: &ResolvedConfig,
3863    files: &'a [fallow_types::discover::DiscoveredFile],
3864    scope_inputs: HealthScopeInputs<'a, R>,
3865) -> HealthScope<'a, R> {
3866    let max_cyclomatic = opts
3867        .thresholds
3868        .max_cyclomatic
3869        .unwrap_or(config.health.max_cyclomatic);
3870    let max_cognitive = opts
3871        .thresholds
3872        .max_cognitive
3873        .unwrap_or(config.health.max_cognitive);
3874    let max_crap = opts.thresholds.max_crap.unwrap_or(config.health.max_crap);
3875    let ignore_set = build_ignore_set(&config.health.ignore);
3876    let HealthScopeInputs {
3877        changed_files,
3878        diff_index,
3879        ws_roots,
3880        group_resolver,
3881    } = scope_inputs;
3882    let file_paths = files.iter().map(|f| (f.id, &f.path)).collect();
3883
3884    HealthScope {
3885        max_cyclomatic,
3886        max_cognitive,
3887        max_crap,
3888        enforce_crap: max_crap > 0.0,
3889        ignore_set,
3890        changed_files,
3891        diff_index,
3892        ws_roots,
3893        group_resolver,
3894        file_paths,
3895    }
3896}
3897
3898fn load_health_coverage(
3899    opts: &HealthOptions<'_>,
3900    config: &ResolvedConfig,
3901) -> Result<Option<scoring::IstanbulCoverage>, ExitCode> {
3902    if let Some(coverage_path) = opts.coverage_inputs.coverage {
3903        return scoring::load_istanbul_coverage(
3904            coverage_path,
3905            opts.coverage_inputs.coverage_root,
3906            Some(&config.root),
3907        )
3908        .map(Some)
3909        .map_err(|e| {
3910            emit_error(&format!("coverage: {e}"), 2, opts.output);
3911            ExitCode::from(2)
3912        });
3913    }
3914
3915    let Some(auto_path) = scoring::auto_detect_coverage(&config.root) else {
3916        return Ok(None);
3917    };
3918    if std::env::var("CI").is_ok_and(|v| !v.is_empty()) {
3919        eprintln!(
3920            "note: using auto-detected coverage at {}; pass --coverage explicitly for deterministic CI scores",
3921            auto_path.display()
3922        );
3923    }
3924    Ok(scoring::load_istanbul_coverage(
3925        &auto_path,
3926        opts.coverage_inputs.coverage_root,
3927        Some(&config.root),
3928    )
3929    .ok())
3930}
3931
3932fn prepare_shared_analysis_output(
3933    opts: &HealthOptions<'_>,
3934    config: &ResolvedConfig,
3935    modules: &[crate::extract::ModuleInfo],
3936    pre_computed: Option<crate::DeadCodeAnalysisArtifacts>,
3937    needed: bool,
3938) -> Result<Option<crate::DeadCodeAnalysisArtifacts>, ExitCode> {
3939    if !needed {
3940        return Ok(None);
3941    }
3942    if let Some(pre) = pre_computed {
3943        return Ok(Some(pre));
3944    }
3945    crate::analyze_with_parse_result(config, modules)
3946        .map(Some)
3947        .map_err(|e| emit_error(&format!("analysis failed: {e}"), 2, opts.output))
3948}
3949
3950#[derive(Clone, Copy)]
3951struct RuntimeCoverageAnalysisScope<'a> {
3952    opts: &'a HealthOptions<'a>,
3953    config: &'a ResolvedConfig,
3954    modules: &'a [crate::extract::ModuleInfo],
3955    shared_analysis_output: Option<&'a crate::DeadCodeAnalysisArtifacts>,
3956    istanbul_coverage: Option<&'a scoring::IstanbulCoverage>,
3957    file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
3958    ignore_set: &'a globset::GlobSet,
3959    changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
3960    ws_roots: Option<&'a [std::path::PathBuf]>,
3961}
3962
3963fn analyze_runtime_coverage(
3964    input: RuntimeCoverageAnalysisScope<'_>,
3965    seams: &HealthSeams<'_>,
3966) -> Result<Option<fallow_output::RuntimeCoverageReport>, ExitCode> {
3967    let Some(production_options) = input.opts.runtime_coverage.as_ref() else {
3968        return Ok(None);
3969    };
3970    let Some(analysis_output) = input.shared_analysis_output else {
3971        return Err(emit_error(
3972            "runtime coverage requires analysis output",
3973            2,
3974            input.opts.output,
3975        ));
3976    };
3977    (seams.runtime_coverage_analyzer)(
3978        production_options,
3979        RuntimeCoverageSeamInput {
3980            root: &input.config.root,
3981            modules: input.modules,
3982            analysis_output,
3983            istanbul_coverage: input.istanbul_coverage,
3984            file_paths: input.file_paths,
3985            ignore_set: input.ignore_set,
3986            changed_files: input.changed_files,
3987            ws_roots: input.ws_roots,
3988            top: input.opts.top,
3989            codeowners_path: input.config.codeowners.as_deref(),
3990            quiet: input.opts.quiet,
3991            output: input.opts.output,
3992        },
3993    )
3994    .map(Some)
3995}
3996
3997struct HealthAnalysisData {
3998    runtime_coverage: Option<fallow_output::RuntimeCoverageReport>,
3999    score_output: Option<scoring::FileScoreOutput>,
4000    files_scored: Option<usize>,
4001    average_maintainability: Option<f64>,
4002    framework_health_facts: Option<FrameworkHealthFacts>,
4003    file_scores_ms: f64,
4004    git_churn_ms: f64,
4005    git_churn_cache_hit: bool,
4006    churn_fetch: Option<hotspots::ChurnFetchResult>,
4007}
4008
4009#[derive(Clone, Copy, Default)]
4010struct FrameworkHealthFacts {
4011    unused_load_data_keys_global_abstain: bool,
4012}
4013
4014fn build_framework_health_diagnostics(
4015    config: &ResolvedConfig,
4016    facts: Option<FrameworkHealthFacts>,
4017) -> Option<fallow_output::FrameworkHealthDiagnostics> {
4018    let facts = facts?;
4019    let detected_frameworks = detect_frameworks(config);
4020    if detected_frameworks.is_empty() {
4021        return None;
4022    }
4023
4024    let mut detectors = Vec::new();
4025    for framework in &detected_frameworks {
4026        add_framework_detectors(&mut detectors, framework, &config.rules, facts);
4027    }
4028
4029    if detectors.is_empty() {
4030        return None;
4031    }
4032
4033    Some(fallow_output::FrameworkHealthDiagnostics {
4034        detected_frameworks,
4035        detectors,
4036    })
4037}
4038
4039fn detect_frameworks(config: &ResolvedConfig) -> Vec<String> {
4040    let mut deps = rustc_hash::FxHashSet::default();
4041    if let Ok(pkg) = PackageJson::load(&config.root.join("package.json")) {
4042        deps.extend(pkg.all_dependency_names());
4043    }
4044    for workspace in fallow_config::discover_workspaces(&config.root) {
4045        if let Ok(pkg) = PackageJson::load(&workspace.root.join("package.json")) {
4046            deps.extend(pkg.all_dependency_names());
4047        }
4048    }
4049
4050    let mut frameworks = Vec::new();
4051    if deps.contains("react") || deps.contains("preact") || deps.contains("next") {
4052        frameworks.push("react".to_string());
4053    }
4054    if deps.contains("next") {
4055        frameworks.push("next".to_string());
4056    }
4057    if deps.contains("vue") || deps.contains("@vue/runtime-core") {
4058        frameworks.push("vue".to_string());
4059    }
4060    if deps.contains("nuxt") {
4061        frameworks.push("nuxt".to_string());
4062    }
4063    if deps.contains("svelte") || deps.contains("@sveltejs/kit") {
4064        frameworks.push("svelte".to_string());
4065    }
4066    if deps.contains("@sveltejs/kit") {
4067        frameworks.push("sveltekit".to_string());
4068    }
4069    if deps.contains("@angular/core") {
4070        frameworks.push("angular".to_string());
4071    }
4072    frameworks.sort_unstable();
4073    frameworks.dedup();
4074    frameworks
4075}
4076
4077fn add_framework_detectors(
4078    detectors: &mut Vec<fallow_output::FrameworkHealthDetector>,
4079    framework: &str,
4080    rules: &fallow_config::RulesConfig,
4081    facts: FrameworkHealthFacts,
4082) {
4083    match framework {
4084        "angular" => add_angular_detectors(detectors, framework, rules),
4085        "next" => add_next_detectors(detectors, framework, rules),
4086        "nuxt" => add_nuxt_detectors(detectors, framework, rules),
4087        "vue" => add_vue_detectors(detectors, framework, rules),
4088        "react" => add_react_detectors(detectors, framework, rules),
4089        "svelte" => add_svelte_detectors(detectors, framework, rules),
4090        "sveltekit" => add_sveltekit_detectors(detectors, framework, rules, facts),
4091        _ => {}
4092    }
4093}
4094
4095fn add_angular_detectors(
4096    detectors: &mut Vec<fallow_output::FrameworkHealthDetector>,
4097    framework: &str,
4098    rules: &fallow_config::RulesConfig,
4099) {
4100    add_detector(
4101        detectors,
4102        framework,
4103        "unrendered-component",
4104        rules.unrendered_components,
4105    );
4106    add_detector(
4107        detectors,
4108        framework,
4109        "unused-component-input",
4110        rules.unused_component_inputs,
4111    );
4112    add_detector(
4113        detectors,
4114        framework,
4115        "unused-component-output",
4116        rules.unused_component_outputs,
4117    );
4118    add_detector(
4119        detectors,
4120        framework,
4121        "unprovided-inject",
4122        rules.unprovided_injects,
4123    );
4124}
4125
4126fn add_next_detectors(
4127    detectors: &mut Vec<fallow_output::FrameworkHealthDetector>,
4128    framework: &str,
4129    rules: &fallow_config::RulesConfig,
4130) {
4131    add_detector(
4132        detectors,
4133        framework,
4134        "invalid-client-export",
4135        rules.invalid_client_export,
4136    );
4137    add_detector(
4138        detectors,
4139        framework,
4140        "mixed-client-server-barrel",
4141        rules.mixed_client_server_barrel,
4142    );
4143    add_detector(
4144        detectors,
4145        framework,
4146        "misplaced-directive",
4147        rules.misplaced_directive,
4148    );
4149    add_detector(
4150        detectors,
4151        framework,
4152        "route-collision",
4153        rules.route_collision,
4154    );
4155    add_detector(
4156        detectors,
4157        framework,
4158        "dynamic-segment-name-conflict",
4159        rules.dynamic_segment_name_conflict,
4160    );
4161    add_detector(
4162        detectors,
4163        framework,
4164        "unused-server-action",
4165        rules.unused_server_actions,
4166    );
4167}
4168
4169fn add_nuxt_detectors(
4170    detectors: &mut Vec<fallow_output::FrameworkHealthDetector>,
4171    framework: &str,
4172    rules: &fallow_config::RulesConfig,
4173) {
4174    add_detector(
4175        detectors,
4176        framework,
4177        "unrendered-component",
4178        rules.unrendered_components,
4179    );
4180    add_detector(
4181        detectors,
4182        framework,
4183        "unused-component-prop",
4184        rules.unused_component_props,
4185    );
4186    add_detector(
4187        detectors,
4188        framework,
4189        "unused-component-emit",
4190        rules.unused_component_emits,
4191    );
4192    add_not_checked_detector(
4193        detectors,
4194        framework,
4195        "unprovided-inject",
4196        "requires_vue_runtime_dependency",
4197    );
4198}
4199
4200fn add_vue_detectors(
4201    detectors: &mut Vec<fallow_output::FrameworkHealthDetector>,
4202    framework: &str,
4203    rules: &fallow_config::RulesConfig,
4204) {
4205    add_detector(
4206        detectors,
4207        framework,
4208        "unrendered-component",
4209        rules.unrendered_components,
4210    );
4211    add_detector(
4212        detectors,
4213        framework,
4214        "unused-component-prop",
4215        rules.unused_component_props,
4216    );
4217    add_detector(
4218        detectors,
4219        framework,
4220        "unused-component-emit",
4221        rules.unused_component_emits,
4222    );
4223    add_detector(
4224        detectors,
4225        framework,
4226        "unprovided-inject",
4227        rules.unprovided_injects,
4228    );
4229}
4230
4231fn add_react_detectors(
4232    detectors: &mut Vec<fallow_output::FrameworkHealthDetector>,
4233    framework: &str,
4234    rules: &fallow_config::RulesConfig,
4235) {
4236    add_detector(
4237        detectors,
4238        framework,
4239        "unused-component-prop",
4240        rules.unused_component_props,
4241    );
4242    add_detector(detectors, framework, "prop-drilling", rules.prop_drilling);
4243    add_detector(detectors, framework, "thin-wrapper", rules.thin_wrapper);
4244    add_detector(
4245        detectors,
4246        framework,
4247        "duplicate-prop-shape",
4248        rules.duplicate_prop_shape,
4249    );
4250}
4251
4252fn add_svelte_detectors(
4253    detectors: &mut Vec<fallow_output::FrameworkHealthDetector>,
4254    framework: &str,
4255    rules: &fallow_config::RulesConfig,
4256) {
4257    add_detector(
4258        detectors,
4259        framework,
4260        "unrendered-component",
4261        rules.unrendered_components,
4262    );
4263    add_detector(
4264        detectors,
4265        framework,
4266        "unused-component-prop",
4267        rules.unused_component_props,
4268    );
4269    add_detector(
4270        detectors,
4271        framework,
4272        "unused-svelte-event",
4273        rules.unused_svelte_events,
4274    );
4275    add_detector(
4276        detectors,
4277        framework,
4278        "unprovided-inject",
4279        rules.unprovided_injects,
4280    );
4281}
4282
4283fn add_sveltekit_detectors(
4284    detectors: &mut Vec<fallow_output::FrameworkHealthDetector>,
4285    framework: &str,
4286    rules: &fallow_config::RulesConfig,
4287    facts: FrameworkHealthFacts,
4288) {
4289    if facts.unused_load_data_keys_global_abstain && rules.unused_load_data_keys != Severity::Off {
4290        detectors.push(fallow_output::FrameworkHealthDetector {
4291            id: "unused-load-data-key".to_string(),
4292            framework: framework.to_string(),
4293            status: fallow_output::FrameworkHealthDetectorStatus::Abstained,
4294            reason: Some("unused_load_data_keys_global_abstain".to_string()),
4295        });
4296    } else {
4297        add_detector(
4298            detectors,
4299            framework,
4300            "unused-load-data-key",
4301            rules.unused_load_data_keys,
4302        );
4303    }
4304}
4305
4306fn add_detector(
4307    detectors: &mut Vec<fallow_output::FrameworkHealthDetector>,
4308    framework: &str,
4309    id: &str,
4310    severity: Severity,
4311) {
4312    let (status, reason) = if severity == Severity::Off {
4313        (
4314            fallow_output::FrameworkHealthDetectorStatus::DisabledByConfig,
4315            Some("disabled_by_config".to_string()),
4316        )
4317    } else {
4318        (fallow_output::FrameworkHealthDetectorStatus::Active, None)
4319    };
4320    detectors.push(fallow_output::FrameworkHealthDetector {
4321        id: id.to_string(),
4322        framework: framework.to_string(),
4323        status,
4324        reason,
4325    });
4326}
4327
4328fn add_not_checked_detector(
4329    detectors: &mut Vec<fallow_output::FrameworkHealthDetector>,
4330    framework: &str,
4331    id: &str,
4332    reason: &str,
4333) {
4334    detectors.push(fallow_output::FrameworkHealthDetector {
4335        id: id.to_string(),
4336        framework: framework.to_string(),
4337        status: fallow_output::FrameworkHealthDetectorStatus::NotChecked,
4338        reason: Some(reason.to_string()),
4339    });
4340}
4341
4342struct HealthRuntimeSectionsInput<'a> {
4343    config: &'a ResolvedConfig,
4344    files: &'a [fallow_types::discover::DiscoveredFile],
4345    modules: &'a [crate::extract::ModuleInfo],
4346    file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
4347    ignore_set: &'a globset::GlobSet,
4348    changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
4349    ws_roots: Option<&'a [std::path::PathBuf]>,
4350    diff_index: Option<&'a fallow_output::DiffIndex>,
4351    loaded_baseline: Option<&'a HealthBaselineData>,
4352    findings: &'a [ComplexityViolation],
4353    analysis_data: HealthAnalysisData,
4354    has_istanbul_coverage: bool,
4355    needs_file_scores: bool,
4356}
4357
4358struct HealthRuntimeSections {
4359    analysis_data: HealthAnalysisData,
4360    derived_sections: HealthDerivedSections,
4361    vital_data: HealthVitalData,
4362}
4363
4364fn prepare_health_runtime_sections(
4365    opts: &HealthOptions<'_>,
4366    mut input: HealthRuntimeSectionsInput<'_>,
4367) -> Result<HealthRuntimeSections, ExitCode> {
4368    let file_scores_slice = health_file_scores_slice(input.analysis_data.score_output.as_ref());
4369    let derived_sections = prepare_health_derived_sections(
4370        opts,
4371        HealthDerivedSectionInput {
4372            config: input.config,
4373            files: input.files,
4374            ignore_set: input.ignore_set,
4375            changed_files: input.changed_files,
4376            ws_roots: input.ws_roots,
4377            file_scores: file_scores_slice,
4378            churn_fetch: input.analysis_data.churn_fetch.take(),
4379            diff_index: input.diff_index,
4380            score_output: input.analysis_data.score_output.as_ref(),
4381            loaded_baseline: input.loaded_baseline,
4382        },
4383    );
4384
4385    finalize_health_runtime_outputs(
4386        opts,
4387        HealthRuntimeFinalizeInput {
4388            config: input.config,
4389            runtime_coverage: &mut input.analysis_data.runtime_coverage,
4390            findings: input.findings,
4391            targets: &derived_sections.targets,
4392            loaded_baseline: input.loaded_baseline,
4393            changed_files: input.changed_files,
4394            diff_index: input.diff_index,
4395        },
4396    )?;
4397
4398    let vital_data = prepare_health_vital_data_from_sections(
4399        opts,
4400        &input,
4401        &derived_sections,
4402        file_scores_slice,
4403    )?;
4404
4405    Ok(HealthRuntimeSections {
4406        analysis_data: input.analysis_data,
4407        derived_sections,
4408        vital_data,
4409    })
4410}
4411
4412fn prepare_health_vital_data_from_sections(
4413    opts: &HealthOptions<'_>,
4414    input: &HealthRuntimeSectionsInput<'_>,
4415    derived_sections: &HealthDerivedSections,
4416    file_scores_slice: &[FileHealthScore],
4417) -> Result<HealthVitalData, ExitCode> {
4418    prepare_health_vital_data(&HealthVitalDataInput {
4419        opts,
4420        modules: input.modules,
4421        file_paths: input.file_paths,
4422        score_output: input.analysis_data.score_output.as_ref(),
4423        file_scores_slice,
4424        hotspots: &derived_sections.hotspots,
4425        dupes_report: derived_sections.dupes_report.as_ref(),
4426        candidate_paths: &derived_sections.candidate_paths,
4427        total_files: input.files.len(),
4428        config: input.config,
4429        ignore_set: input.ignore_set,
4430        changed_files: input.changed_files,
4431        ws_roots: input.ws_roots,
4432        diff_index: input.diff_index,
4433        hotspot_summary: derived_sections.hotspot_summary.as_ref(),
4434        has_istanbul_coverage: input.has_istanbul_coverage,
4435        needs_file_scores: input.needs_file_scores,
4436    })
4437}
4438
4439struct HealthAnalysisDataInput<'a> {
4440    opts: &'a HealthOptions<'a>,
4441    config: &'a ResolvedConfig,
4442    modules: &'a [crate::extract::ModuleInfo],
4443    file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
4444    ignore_set: &'a globset::GlobSet,
4445    changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
4446    ws_roots: Option<&'a [std::path::PathBuf]>,
4447    istanbul_coverage: Option<&'a scoring::IstanbulCoverage>,
4448    pre_computed_analysis: Option<crate::DeadCodeAnalysisArtifacts>,
4449    needs_file_scores: bool,
4450    seams: &'a HealthSeams<'a>,
4451}
4452
4453fn prepare_health_analysis_data(
4454    input: HealthAnalysisDataInput<'_>,
4455) -> Result<HealthAnalysisData, ExitCode> {
4456    let mut input = input;
4457    let needs_analysis_output = input.needs_file_scores || input.opts.runtime_coverage.is_some();
4458    let seams = input.seams;
4459    let mut shared_analysis =
4460        prepare_shared_health_analysis(&mut input, needs_analysis_output, seams)?;
4461
4462    let runtime_coverage = analyze_runtime_coverage(
4463        RuntimeCoverageAnalysisScope {
4464            opts: input.opts,
4465            config: input.config,
4466            modules: input.modules,
4467            shared_analysis_output: shared_analysis.output.as_ref(),
4468            istanbul_coverage: input.istanbul_coverage,
4469            file_paths: input.file_paths,
4470            ignore_set: input.ignore_set,
4471            changed_files: input.changed_files,
4472            ws_roots: input.ws_roots,
4473        },
4474        seams,
4475    )?;
4476
4477    let precomputed_for_scores = shared_analysis.take_for_file_scores(input.needs_file_scores);
4478
4479    let (file_score_result, file_scores_ms, churn_fetch) = compute_file_scores_and_churn(
4480        FileScoresAndChurnInput {
4481            opts: input.opts,
4482            config: input.config,
4483            modules: input.modules,
4484            file_paths: input.file_paths,
4485            changed_files: input.changed_files,
4486            ws_roots: input.ws_roots,
4487            ignore_set: input.ignore_set,
4488            istanbul_coverage: input.istanbul_coverage,
4489            needs_file_scores: input.needs_file_scores,
4490        },
4491        precomputed_for_scores,
4492    )?;
4493    let (git_churn_ms, git_churn_cache_hit) = churn_fetch
4494        .as_ref()
4495        .map_or((0.0, false), |cf| (cf.git_log_ms, cf.cache_hit));
4496    let (score_output, files_scored, average_maintainability) = file_score_result;
4497
4498    print_slow_churn_note(input.opts, churn_fetch.as_ref());
4499
4500    Ok(HealthAnalysisData {
4501        runtime_coverage,
4502        score_output,
4503        files_scored,
4504        average_maintainability,
4505        framework_health_facts: shared_analysis.framework_health_facts,
4506        file_scores_ms,
4507        git_churn_ms,
4508        git_churn_cache_hit,
4509        churn_fetch,
4510    })
4511}
4512
4513struct PreparedSharedHealthAnalysis {
4514    output: Option<crate::DeadCodeAnalysisArtifacts>,
4515    framework_health_facts: Option<FrameworkHealthFacts>,
4516}
4517
4518impl PreparedSharedHealthAnalysis {
4519    fn take_for_file_scores(
4520        &mut self,
4521        needs_file_scores: bool,
4522    ) -> Option<crate::DeadCodeAnalysisArtifacts> {
4523        if needs_file_scores {
4524            self.output.take()
4525        } else {
4526            None
4527        }
4528    }
4529}
4530
4531fn prepare_shared_health_analysis(
4532    input: &mut HealthAnalysisDataInput<'_>,
4533    needs_analysis_output: bool,
4534    seams: &HealthSeams<'_>,
4535) -> Result<PreparedSharedHealthAnalysis, ExitCode> {
4536    let output = prepare_shared_analysis_output(
4537        input.opts,
4538        input.config,
4539        input.modules,
4540        input.pre_computed_analysis.take(),
4541        needs_analysis_output,
4542    )?;
4543    let framework_health_facts = output.as_ref().map(|output| FrameworkHealthFacts {
4544        unused_load_data_keys_global_abstain: output.results.unused_load_data_keys_global_abstain,
4545    });
4546    if let Some(graph) = output.as_ref().and_then(|output| output.graph.as_ref()) {
4547        (seams.note_graph_structure)(graph.module_count(), graph.edge_count());
4548    }
4549
4550    Ok(PreparedSharedHealthAnalysis {
4551        output,
4552        framework_health_facts,
4553    })
4554}
4555
4556type FileScoresAndChurn = (FileScoreResult, f64, Option<hotspots::ChurnFetchResult>);
4557
4558#[derive(Clone, Copy)]
4559struct FileScoresAndChurnInput<'a> {
4560    opts: &'a HealthOptions<'a>,
4561    config: &'a ResolvedConfig,
4562    modules: &'a [crate::extract::ModuleInfo],
4563    file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
4564    changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
4565    ws_roots: Option<&'a [std::path::PathBuf]>,
4566    ignore_set: &'a globset::GlobSet,
4567    istanbul_coverage: Option<&'a scoring::IstanbulCoverage>,
4568    needs_file_scores: bool,
4569}
4570
4571fn compute_file_scores_and_churn(
4572    input: FileScoresAndChurnInput<'_>,
4573    precomputed_for_scores: Option<crate::DeadCodeAnalysisArtifacts>,
4574) -> Result<FileScoresAndChurn, ExitCode> {
4575    let needs_churn = input.opts.hotspots || input.opts.targets;
4576    if input.needs_file_scores && needs_churn {
4577        return std::thread::scope(|s| {
4578            let churn_handle =
4579                s.spawn(|| hotspots::fetch_churn_data(input.opts, &input.config.cache_dir));
4580            let t = Instant::now();
4581            let score_result = compute_filtered_file_scores(FileScoreInput {
4582                config: input.config,
4583                modules: input.modules,
4584                file_paths: input.file_paths,
4585                changed_files: input.changed_files,
4586                ws_roots: input.ws_roots,
4587                ignore_set: input.ignore_set,
4588                output: input.opts.output,
4589                istanbul_coverage: input.istanbul_coverage,
4590                pre_computed: precomputed_for_scores,
4591            })?;
4592            let fs_ms = t.elapsed().as_secs_f64() * 1000.0;
4593            let churn = churn_handle
4594                .join()
4595                .map_err(|_| emit_error("churn thread panicked", 2, input.opts.output))?;
4596            Ok((score_result, fs_ms, churn))
4597        });
4598    }
4599
4600    let t = Instant::now();
4601    let score_result = if input.needs_file_scores {
4602        compute_filtered_file_scores(FileScoreInput {
4603            config: input.config,
4604            modules: input.modules,
4605            file_paths: input.file_paths,
4606            changed_files: input.changed_files,
4607            ws_roots: input.ws_roots,
4608            ignore_set: input.ignore_set,
4609            output: input.opts.output,
4610            istanbul_coverage: input.istanbul_coverage,
4611            pre_computed: precomputed_for_scores,
4612        })?
4613    } else {
4614        (None, None, None)
4615    };
4616    let fs_ms = t.elapsed().as_secs_f64() * 1000.0;
4617    let churn = if needs_churn {
4618        hotspots::fetch_churn_data(input.opts, &input.config.cache_dir)
4619    } else {
4620        None
4621    };
4622    Ok((score_result, fs_ms, churn))
4623}
4624
4625fn print_slow_churn_note(
4626    opts: &HealthOptions<'_>,
4627    churn_fetch: Option<&hotspots::ChurnFetchResult>,
4628) {
4629    if let Some(cf) = churn_fetch
4630        && !cf.cache_hit
4631        && !opts.no_cache
4632        && !opts.quiet
4633        && cf.git_log_ms > 500.0
4634    {
4635        eprintln!(
4636            "{}",
4637            format!(
4638                "  note: git churn analysis took {:.1}s (cached for next run at same HEAD)",
4639                cf.git_log_ms / 1000.0
4640            )
4641            .dimmed()
4642        );
4643    }
4644}
4645
4646fn count_finding_severities(findings: &[ComplexityViolation]) -> (usize, usize, usize) {
4647    let (mut critical, mut high, mut moderate) = (0usize, 0usize, 0usize);
4648    for finding in findings {
4649        match finding.severity {
4650            FindingSeverity::Critical => critical += 1,
4651            FindingSeverity::High => high += 1,
4652            FindingSeverity::Moderate => moderate += 1,
4653        }
4654    }
4655    (critical, high, moderate)
4656}
4657
4658fn apply_health_baseline_and_top(
4659    opts: &HealthOptions<'_>,
4660    config: &ResolvedConfig,
4661    findings: &mut Vec<ComplexityViolation>,
4662) -> Result<Option<HealthBaselineData>, ExitCode> {
4663    let loaded_baseline = if let Some(load_path) = opts.baseline {
4664        Some(load_health_baseline(
4665            load_path,
4666            findings,
4667            &config.root,
4668            opts.quiet,
4669            opts.output,
4670        )?)
4671    } else {
4672        None
4673    };
4674    if let Some(top) = opts.top {
4675        findings.truncate(top);
4676    }
4677    Ok(loaded_baseline)
4678}
4679
4680struct FilteredHotspotInput<'a> {
4681    opts: &'a HealthOptions<'a>,
4682    config: &'a ResolvedConfig,
4683    file_scores_slice: &'a [FileHealthScore],
4684    ignore_set: &'a globset::GlobSet,
4685    ws_roots: Option<&'a [std::path::PathBuf]>,
4686    churn_fetch: Option<hotspots::ChurnFetchResult>,
4687    diff_index: Option<&'a fallow_output::DiffIndex>,
4688}
4689
4690fn compute_filtered_hotspots(
4691    input: FilteredHotspotInput<'_>,
4692) -> (Vec<HotspotEntry>, Option<HotspotSummary>, f64) {
4693    let t = Instant::now();
4694    let (mut hotspots, hotspot_summary) = if let Some(churn_data) = input.churn_fetch {
4695        compute_hotspots(
4696            input.opts,
4697            input.config,
4698            input.file_scores_slice,
4699            input.ignore_set,
4700            input.ws_roots,
4701            churn_data,
4702        )
4703    } else {
4704        (Vec::new(), None)
4705    };
4706    if let Some(diff_index) = input.diff_index {
4707        filter_hotspots_by_diff(&mut hotspots, diff_index, &input.config.root);
4708    }
4709    (
4710        hotspots,
4711        hotspot_summary,
4712        t.elapsed().as_secs_f64() * 1000.0,
4713    )
4714}
4715
4716#[derive(Clone, Copy)]
4717struct FilteredTargetInput<'a> {
4718    opts: &'a HealthOptions<'a>,
4719    score_output: Option<&'a scoring::FileScoreOutput>,
4720    file_scores_slice: &'a [FileHealthScore],
4721    hotspots: &'a [HotspotEntry],
4722    loaded_baseline: Option<&'a HealthBaselineData>,
4723    config: &'a ResolvedConfig,
4724    diff_index: Option<&'a fallow_output::DiffIndex>,
4725    dupes_report: Option<&'a crate::duplicates::DuplicationReport>,
4726}
4727
4728fn compute_filtered_targets(
4729    input: FilteredTargetInput<'_>,
4730) -> (Vec<RefactoringTarget>, Option<TargetThresholds>, f64) {
4731    let t = Instant::now();
4732    let (mut targets, target_thresholds) = compute_targets(&input);
4733    if let Some(diff_index) = input.diff_index {
4734        filter_refactoring_targets_by_diff(&mut targets, diff_index, &input.config.root);
4735    }
4736    (
4737        targets,
4738        target_thresholds,
4739        t.elapsed().as_secs_f64() * 1000.0,
4740    )
4741}
4742
4743fn filter_runtime_coverage_report(
4744    opts: &HealthOptions<'_>,
4745    config: &ResolvedConfig,
4746    report: Option<&mut fallow_output::RuntimeCoverageReport>,
4747    loaded_baseline: Option<&HealthBaselineData>,
4748    changed_files: Option<&rustc_hash::FxHashSet<std::path::PathBuf>>,
4749    diff_index: Option<&fallow_output::DiffIndex>,
4750) {
4751    if let Some(report) = report {
4752        let ctx = RuntimeCoverageFilterContext::new(&config.root)
4753            .with_baseline(loaded_baseline)
4754            .with_top(opts.top)
4755            .with_changed_files(changed_files)
4756            .with_diff_index(diff_index);
4757        apply_runtime_coverage_filters(report, &ctx);
4758    }
4759}
4760
4761fn save_health_baseline_if_requested(
4762    opts: &HealthOptions<'_>,
4763    config: &ResolvedConfig,
4764    findings: &[ComplexityViolation],
4765    runtime_coverage: Option<&fallow_output::RuntimeCoverageReport>,
4766    targets: &[RefactoringTarget],
4767) -> Result<(), ExitCode> {
4768    if let Some(save_path) = opts.save_baseline {
4769        save_health_baseline(&HealthBaselineSaveInput {
4770            save_path,
4771            findings,
4772            runtime_coverage_findings: runtime_coverage
4773                .map_or(&[], |report| report.findings.as_slice()),
4774            targets,
4775            config_root: &config.root,
4776            quiet: opts.quiet,
4777            output: opts.output,
4778        })?;
4779    }
4780    Ok(())
4781}
4782
4783struct HealthRuntimeFinalizeInput<'a> {
4784    config: &'a ResolvedConfig,
4785    runtime_coverage: &'a mut Option<fallow_output::RuntimeCoverageReport>,
4786    findings: &'a [ComplexityViolation],
4787    targets: &'a [RefactoringTarget],
4788    loaded_baseline: Option<&'a HealthBaselineData>,
4789    changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
4790    diff_index: Option<&'a fallow_output::DiffIndex>,
4791}
4792
4793fn finalize_health_runtime_outputs(
4794    opts: &HealthOptions<'_>,
4795    input: HealthRuntimeFinalizeInput<'_>,
4796) -> Result<(), ExitCode> {
4797    let HealthRuntimeFinalizeInput {
4798        config,
4799        runtime_coverage,
4800        findings,
4801        targets,
4802        loaded_baseline,
4803        changed_files,
4804        diff_index,
4805    } = input;
4806
4807    filter_runtime_coverage_report(
4808        opts,
4809        config,
4810        runtime_coverage.as_mut(),
4811        loaded_baseline,
4812        changed_files,
4813        diff_index,
4814    );
4815    save_health_baseline_if_requested(opts, config, findings, runtime_coverage.as_ref(), targets)
4816}
4817
4818fn prepare_health_duplication_data(
4819    opts: &HealthOptions<'_>,
4820    config: &ResolvedConfig,
4821    files: &[fallow_types::discover::DiscoveredFile],
4822    changed_files: Option<&rustc_hash::FxHashSet<std::path::PathBuf>>,
4823    ws_roots: Option<&[std::path::PathBuf]>,
4824    ignore_set: &globset::GlobSet,
4825) -> (
4826    rustc_hash::FxHashSet<std::path::PathBuf>,
4827    Option<crate::duplicates::DuplicationReport>,
4828    f64,
4829) {
4830    let candidate_paths =
4831        collect_candidate_paths(files, config, changed_files, ws_roots, ignore_set);
4832    let (dupes_report, duplication_ms) =
4833        compute_health_duplication_report(opts, config, files, &candidate_paths);
4834    (candidate_paths, dupes_report, duplication_ms)
4835}
4836
4837fn compute_health_duplication_report(
4838    opts: &HealthOptions<'_>,
4839    config: &ResolvedConfig,
4840    files: &[fallow_types::discover::DiscoveredFile],
4841    candidate_paths: &rustc_hash::FxHashSet<std::path::PathBuf>,
4842) -> (Option<crate::duplicates::DuplicationReport>, f64) {
4843    let t = Instant::now();
4844    let dupes_report = if opts.score || opts.targets {
4845        let scoped_files = filter_files_to_paths(files, candidate_paths);
4846        Some(if opts.no_cache {
4847            crate::duplicates::find_duplicates(&config.root, &scoped_files, &config.duplicates)
4848        } else {
4849            crate::duplicates::find_duplicates_cached(
4850                &config.root,
4851                &scoped_files,
4852                &config.duplicates,
4853                &config.cache_dir,
4854            )
4855        })
4856    } else {
4857        None
4858    };
4859    (dupes_report, t.elapsed().as_secs_f64() * 1000.0)
4860}
4861
4862struct HealthVitalData {
4863    vital_signs: fallow_output::VitalSigns,
4864    health_score: Option<HealthScore>,
4865    health_trend: Option<fallow_output::HealthTrend>,
4866    large_functions: Vec<fallow_output::LargeFunctionEntry>,
4867}
4868
4869struct HealthVitalDataInput<'a> {
4870    opts: &'a HealthOptions<'a>,
4871    modules: &'a [crate::extract::ModuleInfo],
4872    file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
4873    score_output: Option<&'a scoring::FileScoreOutput>,
4874    file_scores_slice: &'a [FileHealthScore],
4875    hotspots: &'a [HotspotEntry],
4876    dupes_report: Option<&'a crate::duplicates::DuplicationReport>,
4877    candidate_paths: &'a rustc_hash::FxHashSet<std::path::PathBuf>,
4878    total_files: usize,
4879    config: &'a ResolvedConfig,
4880    ignore_set: &'a globset::GlobSet,
4881    changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
4882    ws_roots: Option<&'a [std::path::PathBuf]>,
4883    diff_index: Option<&'a fallow_output::DiffIndex>,
4884    hotspot_summary: Option<&'a HotspotSummary>,
4885    has_istanbul_coverage: bool,
4886    needs_file_scores: bool,
4887}
4888
4889/// Assign the prop-drilling chain count / max depth onto the vital signs. Prop
4890/// drilling is a whole-project graph signal (the chains live in AnalysisResults,
4891/// surfaced via FileScoreOutput); only populated when the opt-in `prop-drilling`
4892/// rule emitted chains, so the small capped penalty stays dormant by default.
4893fn apply_prop_drilling_metrics(
4894    vital_signs: &mut fallow_output::VitalSigns,
4895    score_output: &scoring::FileScoreOutput,
4896) {
4897    if score_output.prop_drilling_chains.is_empty() {
4898        return;
4899    }
4900    vital_signs.prop_drilling_chain_count =
4901        u32::try_from(score_output.prop_drilling_chains.len()).ok();
4902    vital_signs.prop_drilling_max_depth = score_output
4903        .prop_drilling_chains
4904        .iter()
4905        .map(|c| c.chain.depth)
4906        .max();
4907}
4908
4909/// Assign the descriptive render fan-in blast-radius metric (p95 / high-pct / max
4910/// distinct parents plus a located top-N list) onto the vital signs. Aggregates
4911/// are precomputed in core and ride on FileScoreOutput; non-React runs leave the
4912/// fields `None` (skip_serializing_if), so the JSON contract is unchanged.
4913fn apply_render_fan_in_metrics(
4914    vital_signs: &mut fallow_output::VitalSigns,
4915    score_output: &scoring::FileScoreOutput,
4916    config: &ResolvedConfig,
4917) {
4918    let Some(metric) = score_output.render_fan_in.as_ref() else {
4919        return;
4920    };
4921    vital_signs.p95_render_fan_in = metric.p95_distinct_parents;
4922    vital_signs.render_fan_in_high_pct = metric.high_pct;
4923    // The public headline (`max_render_fan_in`) is the max DISTINCT-PARENTS:
4924    // honest blast radius = the most distinct render LOCATIONS any one
4925    // component is rendered from. `render_sites` (incl. repeats) is secondary.
4926    vital_signs.max_render_fan_in = metric.max_distinct_parents;
4927
4928    // Located top-N list so a consumer sees WHICH component carries the
4929    // headline fan-in, not just the number. The core carrier is sorted by
4930    // (path, component) for run-stability and INCLUDES rendered-nowhere `0`
4931    // entries (for the percentile distribution), so re-sort by
4932    // distinct_parents (the honest headline axis) descending, tie-break on
4933    // render_sites descending, and drop the `0`-fan-in entries here. Final
4934    // tie-break on (path, component) so the cap is deterministic. Cap at a
4935    // small N.
4936    const MAX_TOP_RENDER_FAN_IN: usize = 20;
4937    let mut top: Vec<&fallow_types::results::RenderFanInComponent> = metric
4938        .per_component
4939        .iter()
4940        .filter(|c| c.distinct_parents > 0)
4941        .collect();
4942    top.sort_by(|a, b| {
4943        b.distinct_parents
4944            .cmp(&a.distinct_parents)
4945            .then_with(|| b.render_sites.cmp(&a.render_sites))
4946            .then_with(|| a.file.cmp(&b.file))
4947            .then_with(|| a.component.cmp(&b.component))
4948    });
4949    vital_signs.top_render_fan_in = top
4950        .into_iter()
4951        .take(MAX_TOP_RENDER_FAN_IN)
4952        .map(|c| fallow_output::RenderFanInTopComponent {
4953            component: c.component.clone(),
4954            path: c
4955                .file
4956                .strip_prefix(&config.root)
4957                .unwrap_or(&c.file)
4958                .to_path_buf(),
4959            render_sites: c.render_sites,
4960            distinct_parents: c.distinct_parents,
4961        })
4962        .collect();
4963}
4964
4965/// Compute the scoped vital signs / counts for the candidate subset, then assign
4966/// the prop-drilling and render fan-in metrics onto the vital signs.
4967fn compute_scoped_vital_signs(
4968    input: &HealthVitalDataInput<'_>,
4969    total_files_scoped: usize,
4970    project_subset: &SubsetFilter<'_>,
4971) -> (fallow_output::VitalSigns, fallow_output::VitalSignsCounts) {
4972    let vital_signs_input = VitalSignsAndCountsInput {
4973        score_output: input.score_output,
4974        modules: input.modules,
4975        file_paths: input.file_paths,
4976        needs_file_scores: input.needs_file_scores,
4977        file_scores_slice: input.file_scores_slice,
4978        needs_hotspots: input.opts.hotspots || input.opts.targets,
4979        hotspots: input.hotspots,
4980        total_files: total_files_scoped,
4981        subset: project_subset,
4982    };
4983    let (mut vital_signs, counts) = compute_vital_signs_and_counts(&vital_signs_input);
4984
4985    if let Some(score_output) = input.score_output {
4986        apply_prop_drilling_metrics(&mut vital_signs, score_output);
4987        apply_render_fan_in_metrics(&mut vital_signs, score_output, input.config);
4988    }
4989    (vital_signs, counts)
4990}
4991
4992/// Persist the health snapshot when `--save-snapshot` was requested.
4993fn maybe_save_health_snapshot(
4994    input: &HealthVitalDataInput<'_>,
4995    vital_signs: &fallow_output::VitalSigns,
4996    counts: &fallow_output::VitalSignsCounts,
4997    health_score: Option<&HealthScore>,
4998) -> Result<(), ExitCode> {
4999    if let Some(ref snapshot_path) = input.opts.save_snapshot {
5000        save_snapshot(SnapshotInput {
5001            opts: input.opts,
5002            snapshot_path,
5003            vital_signs,
5004            counts,
5005            hotspot_summary: input.hotspot_summary,
5006            health_score,
5007            coverage_model: Some(active_health_coverage_model(input.has_istanbul_coverage)),
5008        })?;
5009    }
5010    Ok(())
5011}
5012
5013fn prepare_health_vital_data(
5014    input: &HealthVitalDataInput<'_>,
5015) -> Result<HealthVitalData, ExitCode> {
5016    let project_subset = if input.candidate_paths.len() == input.total_files {
5017        SubsetFilter::Full
5018    } else {
5019        SubsetFilter::Paths(input.candidate_paths)
5020    };
5021    let total_files_scoped = input.candidate_paths.len();
5022    let (mut vital_signs, mut counts) =
5023        compute_scoped_vital_signs(input, total_files_scoped, &project_subset);
5024
5025    let health_score = compute_health_score_metrics(
5026        input.opts,
5027        input.dupes_report,
5028        &mut vital_signs,
5029        &mut counts,
5030        total_files_scoped,
5031    );
5032    let large_functions = collect_filtered_large_functions(FilteredLargeFunctionInput {
5033        vital_signs: &vital_signs,
5034        modules: input.modules,
5035        file_paths: input.file_paths,
5036        config: input.config,
5037        ignore_set: input.ignore_set,
5038        changed_files: input.changed_files,
5039        ws_roots: input.ws_roots,
5040        diff_index: input.diff_index,
5041    });
5042    maybe_save_health_snapshot(input, &vital_signs, &counts, health_score.as_ref())?;
5043    let health_trend =
5044        compute_health_trend(input.opts, &vital_signs, &counts, health_score.as_ref());
5045
5046    Ok(HealthVitalData {
5047        vital_signs,
5048        health_score,
5049        health_trend,
5050        large_functions,
5051    })
5052}
5053
5054fn compute_health_score_metrics(
5055    opts: &HealthOptions<'_>,
5056    dupes_report: Option<&crate::duplicates::DuplicationReport>,
5057    vital_signs: &mut fallow_output::VitalSigns,
5058    counts: &mut fallow_output::VitalSignsCounts,
5059    total_files_scoped: usize,
5060) -> Option<HealthScore> {
5061    if opts.score
5062        && let Some(report) = dupes_report
5063    {
5064        apply_duplication_metrics(vital_signs, counts, report);
5065    }
5066    opts.score
5067        .then(|| vital_signs::compute_health_score(vital_signs, total_files_scoped))
5068}
5069
5070#[derive(Clone, Copy)]
5071struct FilteredLargeFunctionInput<'a> {
5072    vital_signs: &'a fallow_output::VitalSigns,
5073    modules: &'a [crate::extract::ModuleInfo],
5074    file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
5075    config: &'a ResolvedConfig,
5076    ignore_set: &'a globset::GlobSet,
5077    changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
5078    ws_roots: Option<&'a [std::path::PathBuf]>,
5079    diff_index: Option<&'a fallow_output::DiffIndex>,
5080}
5081
5082fn collect_filtered_large_functions(
5083    input: FilteredLargeFunctionInput<'_>,
5084) -> Vec<fallow_output::LargeFunctionEntry> {
5085    let large_input = LargeFunctionInput {
5086        vital_signs: input.vital_signs,
5087        modules: input.modules,
5088        file_paths: input.file_paths,
5089        config_root: &input.config.root,
5090        ignore_set: input.ignore_set,
5091        changed_files: input.changed_files,
5092        ws_roots: input.ws_roots,
5093    };
5094    let mut large_functions = collect_large_functions(&large_input);
5095    if let Some(diff_index) = input.diff_index {
5096        filter_large_functions_by_diff(&mut large_functions, diff_index, &input.config.root);
5097    }
5098    large_functions
5099}
5100
5101/// Drop complexity findings whose function body span does NOT overlap any
5102/// added line in the supplied diff. The function spans
5103/// `[line..=line + line_count - 1]`: a hotspot that starts before the
5104/// diff but extends into a touched line counts as overlap. `line_count`
5105/// of zero collapses to `[line..=line]` so older fixture rows without
5106/// extents do not silently match every diff.
5107///
5108/// Paths that cannot be expressed relative to `root` (different drive,
5109/// path-traversal escape) are RETAINED rather than silently dropped:
5110/// surfacing an unfilterable path is better than hiding a finding.
5111fn filter_complexity_findings_by_diff(
5112    findings: &mut Vec<ComplexityViolation>,
5113    diff_index: &fallow_output::DiffIndex,
5114    root: &std::path::Path,
5115) {
5116    findings.retain(|f| {
5117        let Some(rel) = relative_to_root(&f.path, root) else {
5118            return true;
5119        };
5120        let start = u64::from(f.line);
5121        let end = if f.line_count == 0 {
5122            start
5123        } else {
5124            start + u64::from(f.line_count) - 1
5125        };
5126        diff_index.range_overlaps_added(&rel, start, end)
5127    });
5128}
5129
5130/// Drop hotspot entries whose file is not touched by the supplied diff.
5131/// Hotspots are per-file aggregates without a per-line position
5132/// (`HotspotEntry` has no `line` field), so file-level matching is the
5133/// only signal the diff carries. Paths outside `root` are RETAINED for
5134/// the same reason as [`filter_complexity_findings_by_diff`].
5135fn filter_hotspots_by_diff(
5136    hotspots: &mut Vec<fallow_output::HotspotEntry>,
5137    diff_index: &fallow_output::DiffIndex,
5138    root: &std::path::Path,
5139) {
5140    hotspots.retain(|h| match relative_to_root(&h.path, root) {
5141        Some(rel) => diff_index.touches_file(&rel),
5142        None => true,
5143    });
5144}
5145
5146/// Drop refactoring targets whose file is not touched by the diff.
5147/// `RefactoringTarget` is per-file (no line range on the target itself);
5148/// the line-anchored evidence under `target.evidence.complex_functions`
5149/// is left intact for downstream renderers because dropping individual
5150/// evidence rows could turn a multi-function recommendation into a
5151/// confusing zero-evidence entry.
5152fn filter_refactoring_targets_by_diff(
5153    targets: &mut Vec<fallow_output::RefactoringTarget>,
5154    diff_index: &fallow_output::DiffIndex,
5155    root: &std::path::Path,
5156) {
5157    targets.retain(|t| match relative_to_root(&t.path, root) {
5158        Some(rel) => diff_index.touches_file(&rel),
5159        None => true,
5160    });
5161}
5162
5163/// Drop large-function entries whose body span does NOT overlap any added
5164/// line in the supplied diff. Same range semantics as
5165/// [`filter_complexity_findings_by_diff`].
5166fn filter_large_functions_by_diff(
5167    entries: &mut Vec<fallow_output::LargeFunctionEntry>,
5168    diff_index: &fallow_output::DiffIndex,
5169    root: &std::path::Path,
5170) {
5171    entries.retain(|e| {
5172        let Some(rel) = relative_to_root(&e.path, root) else {
5173            return true;
5174        };
5175        let start = u64::from(e.line);
5176        let end = if e.line_count == 0 {
5177            start
5178        } else {
5179            start + u64::from(e.line_count) - 1
5180        };
5181        diff_index.range_overlaps_added(&rel, start, end)
5182    });
5183}
5184
5185fn collect_candidate_paths(
5186    files: &[fallow_types::discover::DiscoveredFile],
5187    config: &ResolvedConfig,
5188    changed_files: Option<&rustc_hash::FxHashSet<std::path::PathBuf>>,
5189    ws_roots: Option<&[std::path::PathBuf]>,
5190    ignore_set: &globset::GlobSet,
5191) -> rustc_hash::FxHashSet<std::path::PathBuf> {
5192    files
5193        .iter()
5194        .filter(|file| {
5195            path_in_health_scope(&file.path, config, changed_files, ws_roots, ignore_set)
5196        })
5197        .map(|file| file.path.clone())
5198        .collect()
5199}
5200
5201fn filter_files_to_paths(
5202    files: &[fallow_types::discover::DiscoveredFile],
5203    candidate_paths: &rustc_hash::FxHashSet<std::path::PathBuf>,
5204) -> Vec<fallow_types::discover::DiscoveredFile> {
5205    files
5206        .iter()
5207        .filter(|file| candidate_paths.contains(&file.path))
5208        .cloned()
5209        .collect()
5210}
5211
5212pub(crate) fn apply_duplication_metrics(
5213    vital_signs: &mut fallow_output::VitalSigns,
5214    counts: &mut fallow_output::VitalSignsCounts,
5215    dupes_report: &crate::duplicates::DuplicationReport,
5216) {
5217    let pct = dupes_report.stats.duplication_percentage;
5218    vital_signs.duplication_pct = Some((pct * 10.0).round() / 10.0);
5219    counts.duplicated_lines = Some(dupes_report.stats.duplicated_lines);
5220    if let Some(ref mut vc) = vital_signs.counts {
5221        vc.duplicated_lines = Some(dupes_report.stats.duplicated_lines);
5222    }
5223}
5224
5225/// Sort findings by the specified criteria.
5226fn sort_findings(findings: &mut [ComplexityViolation], sort: HealthSort) {
5227    match sort {
5228        HealthSort::Severity => findings.sort_by_key(|f| {
5229            std::cmp::Reverse((
5230                exceeded_priority(f.exceeded),
5231                severity_priority(f.severity),
5232                f.crap.is_some(),
5233                f.cyclomatic,
5234                f.cognitive,
5235                f.line_count,
5236            ))
5237        }),
5238        HealthSort::Cyclomatic => findings.sort_by_key(|f| std::cmp::Reverse(f.cyclomatic)),
5239        HealthSort::Cognitive => findings.sort_by_key(|f| std::cmp::Reverse(f.cognitive)),
5240        HealthSort::Lines => findings.sort_by_key(|f| std::cmp::Reverse(f.line_count)),
5241    }
5242}
5243
5244const fn exceeded_priority(exceeded: ExceededThreshold) -> u8 {
5245    match exceeded {
5246        ExceededThreshold::All => 5,
5247        ExceededThreshold::CyclomaticCrap | ExceededThreshold::CognitiveCrap => 4,
5248        ExceededThreshold::Crap => 3,
5249        ExceededThreshold::Both => 2,
5250        ExceededThreshold::Cyclomatic | ExceededThreshold::Cognitive => 1,
5251    }
5252}
5253
5254const fn severity_priority(severity: FindingSeverity) -> u8 {
5255    match severity {
5256        FindingSeverity::Critical => 3,
5257        FindingSeverity::High => 2,
5258        FindingSeverity::Moderate => 1,
5259    }
5260}
5261
5262/// `(score_output, files_scored, average_maintainability)`.
5263type FileScoreResult = (Option<scoring::FileScoreOutput>, Option<usize>, Option<f64>);
5264
5265/// Compute file scores, applying workspace and ignore filters.
5266struct FileScoreInput<'a> {
5267    config: &'a ResolvedConfig,
5268    modules: &'a [crate::extract::ModuleInfo],
5269    file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
5270    changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
5271    ws_roots: Option<&'a [std::path::PathBuf]>,
5272    ignore_set: &'a globset::GlobSet,
5273    output: OutputFormat,
5274    istanbul_coverage: Option<&'a scoring::IstanbulCoverage>,
5275    pre_computed: Option<crate::DeadCodeAnalysisArtifacts>,
5276}
5277
5278fn compute_filtered_file_scores(input: FileScoreInput<'_>) -> Result<FileScoreResult, ExitCode> {
5279    let analysis_output = if let Some(pre) = input.pre_computed {
5280        pre
5281    } else {
5282        crate::analyze_with_parse_result(input.config, input.modules)
5283            .map_err(|e| emit_error(&format!("analysis failed: {e}"), 2, input.output))?
5284    };
5285    match compute_file_scores(
5286        input.modules,
5287        input.file_paths,
5288        input.changed_files,
5289        analysis_output,
5290        input.istanbul_coverage,
5291        &input.config.root,
5292    ) {
5293        Ok(mut output) => {
5294            if let Some(ws) = input.ws_roots {
5295                output
5296                    .scores
5297                    .retain(|s| ws.iter().any(|r| s.path.starts_with(r)));
5298            }
5299            if !input.ignore_set.is_empty() {
5300                output.scores.retain(|s| {
5301                    let relative = s.path.strip_prefix(&input.config.root).unwrap_or(&s.path);
5302                    !input.ignore_set.is_match(relative)
5303                });
5304            }
5305            filter_coverage_gaps(
5306                &mut output.coverage.report,
5307                &mut output.coverage.runtime_paths,
5308                input.config,
5309                input.changed_files,
5310                input.ws_roots,
5311                input.ignore_set,
5312            );
5313            let total_scored = output.scores.len();
5314            let avg = if total_scored > 0 {
5315                let sum: f64 = output.scores.iter().map(|s| s.maintainability_index).sum();
5316                Some((sum / total_scored as f64 * 10.0).round() / 10.0)
5317            } else {
5318                None
5319            };
5320            Ok((Some(output), Some(total_scored), avg))
5321        }
5322        Err(e) => {
5323            eprintln!("Warning: failed to compute file scores: {e}");
5324            Ok((None, Some(0), None))
5325        }
5326    }
5327}
5328
5329/// Compute refactoring targets when requested, applying baseline and top filters.
5330fn compute_targets(
5331    input: &FilteredTargetInput<'_>,
5332) -> (Vec<RefactoringTarget>, Option<TargetThresholds>) {
5333    if !input.opts.targets {
5334        return (Vec::new(), None);
5335    }
5336    let Some(output) = input.score_output else {
5337        return (Vec::new(), None);
5338    };
5339    let clone_siblings = input
5340        .dupes_report
5341        .map_or_else(rustc_hash::FxHashMap::default, |report| {
5342            targets::build_clone_sibling_evidence(report)
5343        });
5344    let target_aux = TargetAuxData::from_output(output, &clone_siblings);
5345    let (mut tgts, thresholds) =
5346        compute_refactoring_targets(input.file_scores_slice, &target_aux, input.hotspots);
5347    if let Some(baseline) = input.loaded_baseline {
5348        tgts = filter_new_health_targets(tgts, baseline, &input.config.root);
5349    }
5350    if let Some(ref effort) = input.opts.effort {
5351        tgts.retain(|t| t.effort == *effort);
5352    }
5353    if let Some(top) = input.opts.top {
5354        tgts.truncate(top);
5355    }
5356    (tgts, Some(thresholds))
5357}
5358
5359fn path_in_health_scope(
5360    path: &std::path::Path,
5361    config: &ResolvedConfig,
5362    changed_files: Option<&rustc_hash::FxHashSet<std::path::PathBuf>>,
5363    ws_roots: Option<&[std::path::PathBuf]>,
5364    ignore_set: &globset::GlobSet,
5365) -> bool {
5366    if let Some(changed) = changed_files
5367        && !changed.contains(path)
5368    {
5369        return false;
5370    }
5371    if let Some(ws) = ws_roots
5372        && !ws.iter().any(|r| path.starts_with(r))
5373    {
5374        return false;
5375    }
5376    if !ignore_set.is_empty() {
5377        let relative = path.strip_prefix(&config.root).unwrap_or(path);
5378        if ignore_set.is_match(relative) {
5379            return false;
5380        }
5381    }
5382    true
5383}
5384
5385fn filter_coverage_gaps(
5386    coverage_gaps: &mut CoverageGaps,
5387    runtime_paths: &mut Vec<std::path::PathBuf>,
5388    config: &ResolvedConfig,
5389    changed_files: Option<&rustc_hash::FxHashSet<std::path::PathBuf>>,
5390    ws_roots: Option<&[std::path::PathBuf]>,
5391    ignore_set: &globset::GlobSet,
5392) {
5393    runtime_paths
5394        .retain(|path| path_in_health_scope(path, config, changed_files, ws_roots, ignore_set));
5395    coverage_gaps.files.retain(|item| {
5396        path_in_health_scope(&item.file.path, config, changed_files, ws_roots, ignore_set)
5397    });
5398    coverage_gaps.exports.retain(|item| {
5399        path_in_health_scope(
5400            &item.export.path,
5401            config,
5402            changed_files,
5403            ws_roots,
5404            ignore_set,
5405        )
5406    });
5407
5408    runtime_paths.sort();
5409    runtime_paths.dedup();
5410
5411    let runtime_files = runtime_paths.len();
5412    let untested_files = coverage_gaps.files.len();
5413    let covered_files = runtime_files.saturating_sub(untested_files);
5414    coverage_gaps.summary = scoring::build_coverage_summary(
5415        runtime_files,
5416        covered_files,
5417        untested_files,
5418        coverage_gaps.exports.len(),
5419    );
5420}
5421
5422/// Subset selector used when scoping `vital_signs`, `health_score`, and
5423/// `analysis_counts` to a workspace package or a `--group-by` bucket.
5424///
5425/// `Full` skips filtering entirely (project-wide). `Paths` matches files whose
5426/// absolute path is in the given set (exact match), which is what scoped
5427/// project runs and `--group-by` use to keep every score input on the same
5428/// filtered file set.
5429pub enum SubsetFilter<'a> {
5430    Full,
5431    Paths(&'a rustc_hash::FxHashSet<std::path::PathBuf>),
5432}
5433
5434impl SubsetFilter<'_> {
5435    pub fn is_full(&self) -> bool {
5436        matches!(self, Self::Full)
5437    }
5438    pub fn matches(&self, path: &std::path::Path) -> bool {
5439        match self {
5440            Self::Full => true,
5441            Self::Paths(set) => set.contains(path),
5442        }
5443    }
5444}
5445
5446/// Build vital signs and counts for the slice of files selected by `subset`.
5447///
5448/// When `subset` is anything other than `SubsetFilter::Full`, per-module
5449/// aggregates (cyclomatic distribution, total LOC, unit profiles) are
5450/// restricted to modules in the subset, the analysis counts (`dead_files`,
5451/// `dead_exports`, `unused_deps`, `circular_deps`, `total_exports`) are
5452/// recomputed from the snapshot for the same subset, and `total_files` should
5453/// already reflect the subset-scoped count.
5454pub(crate) struct VitalSignsAndCountsInput<'a> {
5455    pub(crate) score_output: Option<&'a scoring::FileScoreOutput>,
5456    pub(crate) modules: &'a [crate::extract::ModuleInfo],
5457    pub(crate) file_paths:
5458        &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
5459    pub(crate) needs_file_scores: bool,
5460    pub(crate) file_scores_slice: &'a [FileHealthScore],
5461    pub(crate) needs_hotspots: bool,
5462    pub(crate) hotspots: &'a [HotspotEntry],
5463    pub(crate) total_files: usize,
5464    pub(crate) subset: &'a SubsetFilter<'a>,
5465}
5466
5467pub(crate) fn compute_vital_signs_and_counts(
5468    input: &VitalSignsAndCountsInput<'_>,
5469) -> (fallow_output::VitalSigns, fallow_output::VitalSignsCounts) {
5470    let analysis_counts = input.score_output.map(|o| {
5471        o.analysis_snapshot
5472            .counts_for(input.subset, &o.analysis_counts)
5473    });
5474    let module_filter_set: Option<rustc_hash::FxHashSet<crate::discover::FileId>> =
5475        if input.subset.is_full() {
5476            None
5477        } else {
5478            Some(
5479                input
5480                    .modules
5481                    .iter()
5482                    .filter_map(|m| {
5483                        let path = input.file_paths.get(&m.file_id)?;
5484                        if input.subset.matches(path) {
5485                            Some(m.file_id)
5486                        } else {
5487                            None
5488                        }
5489                    })
5490                    .collect(),
5491            )
5492        };
5493    let vs_input = vital_signs::VitalSignsInput {
5494        modules: input.modules,
5495        module_filter: module_filter_set.as_ref(),
5496        file_scores: if input.needs_file_scores {
5497            Some(input.file_scores_slice)
5498        } else {
5499            None
5500        },
5501        hotspots: if input.needs_hotspots {
5502            Some(input.hotspots)
5503        } else {
5504            None
5505        },
5506        total_files: input.total_files,
5507        analysis_counts,
5508    };
5509    let signs = vital_signs::compute_vital_signs(&vs_input);
5510    let counts = vital_signs::build_counts(&vs_input);
5511    (signs, counts)
5512}
5513
5514/// Save a vital signs snapshot to disk if requested.
5515struct SnapshotInput<'a> {
5516    opts: &'a HealthOptions<'a>,
5517    snapshot_path: &'a std::path::Path,
5518    vital_signs: &'a fallow_output::VitalSigns,
5519    counts: &'a fallow_output::VitalSignsCounts,
5520    hotspot_summary: Option<&'a fallow_output::HotspotSummary>,
5521    health_score: Option<&'a fallow_output::HealthScore>,
5522    coverage_model: Option<fallow_output::CoverageModel>,
5523}
5524
5525fn save_snapshot(input: SnapshotInput<'_>) -> Result<(), ExitCode> {
5526    let shallow = input.hotspot_summary.is_some_and(|s| s.shallow_clone);
5527    let snapshot = vital_signs::build_snapshot(
5528        input.vital_signs.clone(),
5529        input.counts.clone(),
5530        input.opts.root,
5531        shallow,
5532        input.health_score,
5533        input.coverage_model,
5534    );
5535    let explicit = if input.snapshot_path.as_os_str().is_empty() {
5536        None
5537    } else {
5538        Some(input.snapshot_path)
5539    };
5540    match vital_signs::save_snapshot(&snapshot, input.opts.root, explicit) {
5541        Ok(saved_path) => {
5542            if !input.opts.quiet {
5543                eprintln!("Saved vital signs snapshot to {}", saved_path.display());
5544            }
5545            Ok(())
5546        }
5547        Err(e) => Err(emit_error(&e, 2, input.opts.output)),
5548    }
5549}
5550
5551/// Compute health trend from historical snapshots if requested.
5552fn compute_health_trend(
5553    opts: &HealthOptions<'_>,
5554    vital_signs: &fallow_output::VitalSigns,
5555    counts: &fallow_output::VitalSignsCounts,
5556    health_score: Option<&fallow_output::HealthScore>,
5557) -> Option<fallow_output::HealthTrend> {
5558    if !opts.trend {
5559        return None;
5560    }
5561    if opts.changed_since.is_some() && !opts.quiet {
5562        eprintln!(
5563            "warning: --trend comparison may be inaccurate with --changed-since; \
5564             snapshots are typically from full-project runs"
5565        );
5566    }
5567    let snapshots = vital_signs::load_snapshots(opts.root);
5568    if snapshots.is_empty() && !opts.quiet {
5569        eprintln!(
5570            "No snapshots found. Run `fallow health --save-snapshot` to save a \
5571             baseline, then use --trend on subsequent runs to track progress."
5572        );
5573    }
5574    vital_signs::compute_trend(
5575        vital_signs,
5576        counts,
5577        health_score.map(|s| s.score),
5578        &snapshots,
5579    )
5580}
5581
5582pub(crate) struct HealthReportAssembly {
5583    pub(crate) report_coverage_gaps: bool,
5584    pub(crate) findings: Vec<ComplexityViolation>,
5585    pub(crate) threshold_overrides: Vec<fallow_output::ThresholdOverrideState>,
5586    pub(crate) files_analyzed: usize,
5587    pub(crate) total_functions: usize,
5588    pub(crate) total_above_threshold: usize,
5589    pub(crate) max_cyclomatic: u16,
5590    pub(crate) max_cognitive: u16,
5591    pub(crate) max_crap: f64,
5592    pub(crate) files_scored: Option<usize>,
5593    pub(crate) average_maintainability: Option<f64>,
5594    pub(crate) vital_signs: fallow_output::VitalSigns,
5595    pub(crate) health_score: Option<fallow_output::HealthScore>,
5596    pub(crate) score_output: Option<scoring::FileScoreOutput>,
5597    pub(crate) hotspots: Vec<HotspotEntry>,
5598    pub(crate) hotspot_summary: Option<fallow_output::HotspotSummary>,
5599    pub(crate) targets: Vec<RefactoringTarget>,
5600    pub(crate) target_thresholds: Option<TargetThresholds>,
5601    pub(crate) health_trend: Option<fallow_output::HealthTrend>,
5602    pub(crate) has_istanbul_coverage: bool,
5603    pub(crate) runtime_coverage: Option<fallow_output::RuntimeCoverageReport>,
5604    pub(crate) framework_health: Option<fallow_output::FrameworkHealthDiagnostics>,
5605    pub(crate) large_functions: Vec<LargeFunctionEntry>,
5606    pub(crate) sev_critical: usize,
5607    pub(crate) sev_high: usize,
5608    pub(crate) sev_moderate: usize,
5609}
5610
5611/// Collect functions exceeding 60 LOC when the unit size risk profile warrants it.
5612///
5613/// Only populated when `very_high_risk >= 3%` in the unit size profile (same threshold
5614/// that triggers showing the risk profile line). Sorted by line count descending.
5615struct LargeFunctionInput<'a> {
5616    vital_signs: &'a fallow_output::VitalSigns,
5617    modules: &'a [crate::extract::ModuleInfo],
5618    file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
5619    config_root: &'a std::path::Path,
5620    ignore_set: &'a globset::GlobSet,
5621    changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
5622    ws_roots: Option<&'a [std::path::PathBuf]>,
5623}
5624
5625fn collect_large_functions(input: &LargeFunctionInput<'_>) -> Vec<LargeFunctionEntry> {
5626    let dominated = input
5627        .vital_signs
5628        .unit_size_profile
5629        .as_ref()
5630        .is_some_and(|p| p.very_high_risk >= 3.0);
5631    if !dominated {
5632        return Vec::new();
5633    }
5634
5635    let mut entries = Vec::new();
5636    for module in input.modules {
5637        let Some(&path) = input.file_paths.get(&module.file_id) else {
5638            continue;
5639        };
5640        let relative = path.strip_prefix(input.config_root).unwrap_or(path);
5641        if input.ignore_set.is_match(relative) {
5642            continue;
5643        }
5644        if let Some(changed) = input.changed_files
5645            && !changed.contains(path.as_path())
5646        {
5647            continue;
5648        }
5649        if let Some(ws) = input.ws_roots
5650            && !ws.iter().any(|r| path.starts_with(r))
5651        {
5652            continue;
5653        }
5654        for func in &module.complexity {
5655            if func.line_count > 60 {
5656                entries.push(LargeFunctionEntry {
5657                    path: path.clone(),
5658                    name: func.name.clone(),
5659                    line: func.line,
5660                    line_count: func.line_count,
5661                });
5662            }
5663        }
5664    }
5665    entries.sort_by_key(|e| std::cmp::Reverse(e.line_count));
5666    entries
5667}
5668
5669/// Build a glob set from health ignore patterns.
5670///
5671/// User patterns were validated at config load time
5672/// (see `FallowConfig::validate_user_globs`).
5673#[expect(
5674    clippy::expect_used,
5675    reason = "health ignore globs are validated before health analysis"
5676)]
5677fn build_ignore_set(patterns: &[String]) -> globset::GlobSet {
5678    let mut builder = globset::GlobSetBuilder::new();
5679    for pattern in patterns {
5680        builder.add(
5681            globset::Glob::new(pattern)
5682                .expect("health.ignore pattern was validated at config load time"),
5683        );
5684    }
5685    builder
5686        .build()
5687        .unwrap_or_else(|_| globset::GlobSet::empty())
5688}
5689
5690/// Collect health findings from parsed modules, applying ignore, changed-since,
5691/// and workspace filters. The returned `files_analyzed` / `total_functions`
5692/// counters reflect only modules that pass every filter so the rendered
5693/// summary matches the produced findings.
5694#[expect(
5695    clippy::too_many_arguments,
5696    reason = "filter pipeline mirrors compute_filtered_file_scores"
5697)]
5698#[cfg(test)]
5699fn collect_findings(
5700    modules: &[crate::extract::ModuleInfo],
5701    file_paths: &rustc_hash::FxHashMap<crate::discover::FileId, &std::path::PathBuf>,
5702    config_root: &std::path::Path,
5703    ignore_set: &globset::GlobSet,
5704    changed_files: Option<&rustc_hash::FxHashSet<std::path::PathBuf>>,
5705    ws_roots: Option<&[std::path::PathBuf]>,
5706    max_cyclomatic: u16,
5707    max_cognitive: u16,
5708    complexity_breakdown: bool,
5709) -> (Vec<ComplexityViolation>, usize, usize) {
5710    let global = GlobalHealthThresholds {
5711        cyclomatic: max_cyclomatic,
5712        cognitive: max_cognitive,
5713        crap: 30.0,
5714    };
5715    let resolver = ThresholdOverrideResolver::new(&[], global);
5716    let mut tracker = ThresholdOverrideStateTracker::default();
5717    let mut input = CollectFindingsInput {
5718        modules,
5719        file_paths,
5720        config_root,
5721        ignore_set,
5722        changed_files,
5723        ws_roots,
5724        threshold_resolver: &resolver,
5725        threshold_state_tracker: &mut tracker,
5726        complexity_breakdown,
5727    };
5728    collect_findings_with_resolver(&mut input)
5729}
5730
5731struct CollectFindingsInput<'a> {
5732    modules: &'a [crate::extract::ModuleInfo],
5733    file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
5734    config_root: &'a std::path::Path,
5735    ignore_set: &'a globset::GlobSet,
5736    changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
5737    ws_roots: Option<&'a [std::path::PathBuf]>,
5738    threshold_resolver: &'a ThresholdOverrideResolver,
5739    threshold_state_tracker: &'a mut ThresholdOverrideStateTracker,
5740    complexity_breakdown: bool,
5741}
5742
5743fn collect_findings_with_resolver(
5744    input: &mut CollectFindingsInput<'_>,
5745) -> (Vec<ComplexityViolation>, usize, usize) {
5746    let mut files_analyzed = 0usize;
5747    let mut total_functions = 0usize;
5748    let mut findings: Vec<ComplexityViolation> = Vec::new();
5749
5750    for module in input.modules {
5751        let Some((path, relative)) = collect_findings_module_path(input, module) else {
5752            continue;
5753        };
5754
5755        files_analyzed += 1;
5756        // Precompute the per-function React hook profile ONCE per module from the
5757        // cached `hook_uses` IR (the sole reader of `module.hook_uses`). Aligned
5758        // by index to `module.complexity`; all-`None` at zero cost for non-React
5759        // files (empty `hook_uses`).
5760        let hook_profiles = react_hooks::build_module_hook_profiles(module);
5761        for (fc_idx, fc) in module.complexity.iter().enumerate() {
5762            total_functions += 1;
5763            if crate::suppress::is_suppressed(
5764                &module.suppressions,
5765                fc.line,
5766                crate::suppress::IssueKind::Complexity,
5767            ) {
5768                continue;
5769            }
5770            let react_hook_profile = hook_profiles.get(fc_idx).cloned().flatten();
5771            if let Some(finding) =
5772                collect_complexity_finding(input, path, relative, fc, react_hook_profile)
5773            {
5774                findings.push(finding);
5775            }
5776        }
5777    }
5778
5779    (findings, files_analyzed, total_functions)
5780}
5781
5782fn collect_findings_module_path<'a>(
5783    input: &CollectFindingsInput<'a>,
5784    module: &crate::extract::ModuleInfo,
5785) -> Option<(&'a std::path::PathBuf, &'a std::path::Path)> {
5786    let &path = input.file_paths.get(&module.file_id)?;
5787    let relative = path.strip_prefix(input.config_root).unwrap_or(path);
5788    if input.ignore_set.is_match(relative) {
5789        return None;
5790    }
5791    if let Some(changed) = input.changed_files
5792        && !changed.contains(path)
5793    {
5794        return None;
5795    }
5796    if let Some(ws) = input.ws_roots
5797        && !ws.iter().any(|root| path.starts_with(root))
5798    {
5799        return None;
5800    }
5801    Some((path, relative))
5802}
5803
5804fn collect_complexity_finding(
5805    input: &mut CollectFindingsInput<'_>,
5806    path: &std::path::Path,
5807    relative: &std::path::Path,
5808    fc: &fallow_types::extract::FunctionComplexity,
5809    react_hook_profile: Option<fallow_output::ReactHookProfile>,
5810) -> Option<ComplexityViolation> {
5811    let (applied_thresholds, matched_overrides) =
5812        input.threshold_resolver.resolve(relative, &fc.name);
5813    input.threshold_state_tracker.record_complexity(
5814        ComplexityFunctionContext {
5815            path,
5816            function: &fc.name,
5817            cyclomatic: fc.cyclomatic,
5818            cognitive: fc.cognitive,
5819        },
5820        &matched_overrides,
5821        input.threshold_resolver.global,
5822    );
5823    let exceeds_cyclomatic = fc.cyclomatic > applied_thresholds.effective.max_cyclomatic;
5824    let exceeds_cognitive = fc.cognitive > applied_thresholds.effective.max_cognitive;
5825    if !exceeds_cyclomatic && !exceeds_cognitive {
5826        return None;
5827    }
5828
5829    Some(ComplexityViolation {
5830        path: path.to_path_buf(),
5831        name: fc.name.clone(),
5832        line: fc.line,
5833        col: fc.col,
5834        cyclomatic: fc.cyclomatic,
5835        cognitive: fc.cognitive,
5836        line_count: fc.line_count,
5837        param_count: fc.param_count,
5838        react_hook_count: fc.react_hook_count,
5839        react_jsx_max_depth: fc.react_jsx_max_depth,
5840        react_prop_count: fc.react_prop_count,
5841        react_hook_profile,
5842        exceeded: ExceededThreshold::from_bools(exceeds_cyclomatic, exceeds_cognitive, false),
5843        severity: compute_finding_severity(
5844            fc.cognitive,
5845            fc.cyclomatic,
5846            None,
5847            DEFAULT_COGNITIVE_HIGH,
5848            DEFAULT_COGNITIVE_CRITICAL,
5849            DEFAULT_CYCLOMATIC_HIGH,
5850            DEFAULT_CYCLOMATIC_CRITICAL,
5851        ),
5852        crap: None,
5853        coverage_pct: None,
5854        coverage_tier: None,
5855        coverage_source: None,
5856        inherited_from: None,
5857        component_rollup: None,
5858        contributions: contributions_for(input.complexity_breakdown, fc),
5859        effective_thresholds: applied_thresholds
5860            .override_index
5861            .map(|_| applied_thresholds.effective),
5862        threshold_source: applied_thresholds
5863            .override_index
5864            .map(|_| fallow_output::ThresholdSource::Override),
5865    })
5866}
5867
5868/// Clone the per-decision-point breakdown onto a finding only when the caller
5869/// opted in via `health --complexity-breakdown`; otherwise leave it empty so it
5870/// is omitted from JSON.
5871fn contributions_for(
5872    complexity_breakdown: bool,
5873    fc: &fallow_types::extract::FunctionComplexity,
5874) -> Vec<fallow_types::extract::ComplexityContribution> {
5875    if complexity_breakdown {
5876        fc.contributions.clone()
5877    } else {
5878        Vec::new()
5879    }
5880}
5881
5882/// Merge per-function CRAP data into an existing complexity findings vector.
5883///
5884/// Functions that only exceed `--max-crap` (without exceeding cyclomatic or
5885/// cognitive) become new findings. Functions that already produced a finding
5886/// for cyclomatic/cognitive get their `crap` and `coverage_pct` fields
5887/// populated, and the `exceeded` discriminant plus `severity` are recomputed
5888/// to reflect CRAP's contribution.
5889struct CrapFindingMergeInput<'a> {
5890    modules: &'a [crate::extract::ModuleInfo],
5891    file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
5892    config_root: &'a std::path::Path,
5893    ignore_set: &'a globset::GlobSet,
5894    changed_files: Option<&'a rustc_hash::FxHashSet<std::path::PathBuf>>,
5895    ws_roots: Option<&'a [std::path::PathBuf]>,
5896    per_function_crap: &'a rustc_hash::FxHashMap<std::path::PathBuf, Vec<scoring::PerFunctionCrap>>,
5897    template_inherit_provenance: &'a rustc_hash::FxHashMap<std::path::PathBuf, std::path::PathBuf>,
5898    complexity_breakdown: bool,
5899    threshold_resolver: &'a ThresholdOverrideResolver,
5900    threshold_state_tracker: &'a mut ThresholdOverrideStateTracker,
5901}
5902
5903type ComplexityByPosition<'a> = rustc_hash::FxHashMap<
5904    &'a std::path::Path,
5905    rustc_hash::FxHashMap<(u32, u32), &'a fallow_types::extract::FunctionComplexity>,
5906>;
5907
5908/// The precomputed position-keyed lookup maps shared across the CRAP merge pass:
5909/// existing-finding index, per-function complexity, React hook profiles, and
5910/// per-path suppressions.
5911struct CrapMergeMaps<'a> {
5912    finding_index: rustc_hash::FxHashMap<(std::path::PathBuf, u32, u32), usize>,
5913    complexity_by_pos: ComplexityByPosition<'a>,
5914    hook_profiles_by_pos: rustc_hash::FxHashMap<
5915        &'a std::path::Path,
5916        rustc_hash::FxHashMap<(u32, u32), fallow_output::ReactHookProfile>,
5917    >,
5918    suppressions_by_path:
5919        rustc_hash::FxHashMap<&'a std::path::Path, &'a Vec<crate::suppress::Suppression>>,
5920}
5921
5922/// Process one path's per-function CRAP entries: record threshold state, skip
5923/// below-threshold / suppressed frames, then merge into an existing finding or
5924/// append a new one to `new_findings`.
5925fn process_crap_findings_for_path(
5926    path: &std::path::Path,
5927    per_fn: &[scoring::PerFunctionCrap],
5928    maps: &CrapMergeMaps<'_>,
5929    findings: &mut [ComplexityViolation],
5930    new_findings: &mut Vec<ComplexityViolation>,
5931    input: &mut CrapFindingMergeInput<'_>,
5932) {
5933    for pf in per_fn {
5934        let Some(fc) = maps
5935            .complexity_by_pos
5936            .get(path)
5937            .and_then(|m| m.get(&(pf.line, pf.col)).copied())
5938        else {
5939            continue;
5940        };
5941        let relative = path.strip_prefix(input.config_root).unwrap_or(path);
5942        let (applied_thresholds, matched_overrides) =
5943            input.threshold_resolver.resolve(relative, &fc.name);
5944        input.threshold_state_tracker.record_crap(
5945            path,
5946            &fc.name,
5947            MeasuredThresholdMetrics {
5948                cyclomatic: fc.cyclomatic,
5949                cognitive: fc.cognitive,
5950                crap: pf.crap,
5951            },
5952            &matched_overrides,
5953            input.threshold_resolver.global,
5954        );
5955        if pf.crap < applied_thresholds.effective.max_crap
5956            || crap_is_suppressed(path, pf, &maps.suppressions_by_path)
5957        {
5958            continue;
5959        }
5960
5961        if let Some(&idx) = maps
5962            .finding_index
5963            .get(&(path.to_path_buf(), pf.line, pf.col))
5964        {
5965            merge_existing_crap_finding(&mut findings[idx], path, pf, input, applied_thresholds);
5966        } else {
5967            let hook_profile = maps
5968                .hook_profiles_by_pos
5969                .get(path)
5970                .and_then(|m| m.get(&(pf.line, pf.col)).cloned());
5971            new_findings.push(new_crap_finding(
5972                path,
5973                pf,
5974                fc,
5975                hook_profile,
5976                input,
5977                applied_thresholds,
5978            ));
5979        }
5980    }
5981}
5982
5983fn merge_crap_findings(
5984    findings: &mut Vec<ComplexityViolation>,
5985    input: &mut CrapFindingMergeInput<'_>,
5986) {
5987    // Copy the `'a` references out so the lookup maps and the per-function map
5988    // borrow the underlying analysis data, not `input`, leaving `input` free to
5989    // be passed mutably into the per-path processor below.
5990    let modules = input.modules;
5991    let file_paths = input.file_paths;
5992    let per_function_crap = input.per_function_crap;
5993    let maps = CrapMergeMaps {
5994        finding_index: build_complexity_finding_index(findings),
5995        complexity_by_pos: build_complexity_by_position(modules, file_paths),
5996        hook_profiles_by_pos: build_hook_profiles_by_position(modules, file_paths),
5997        suppressions_by_path: build_complexity_suppressions_by_path(modules, file_paths),
5998    };
5999
6000    let mut new_findings: Vec<ComplexityViolation> = Vec::new();
6001    for (path, per_fn) in per_function_crap {
6002        if !crap_path_in_scope(path, input) {
6003            continue;
6004        }
6005        process_crap_findings_for_path(path, per_fn, &maps, findings, &mut new_findings, input);
6006    }
6007    findings.extend(new_findings);
6008}
6009
6010fn build_complexity_finding_index(
6011    findings: &[ComplexityViolation],
6012) -> rustc_hash::FxHashMap<(std::path::PathBuf, u32, u32), usize> {
6013    findings
6014        .iter()
6015        .enumerate()
6016        .map(|(idx, f)| ((f.path.clone(), f.line, f.col), idx))
6017        .collect()
6018}
6019
6020fn build_complexity_by_position<'a>(
6021    modules: &'a [crate::extract::ModuleInfo],
6022    file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
6023) -> ComplexityByPosition<'a> {
6024    let mut complexity_by_pos: ComplexityByPosition<'a> = rustc_hash::FxHashMap::default();
6025    for module in modules {
6026        let Some(&path) = file_paths.get(&module.file_id) else {
6027            continue;
6028        };
6029        let entry = complexity_by_pos.entry(path.as_path()).or_default();
6030        for fc in &module.complexity {
6031            entry.insert((fc.line, fc.col), fc);
6032        }
6033    }
6034    complexity_by_pos
6035}
6036
6037/// Build a `path -> (line, col) -> ReactHookProfile` map by precomputing each
6038/// module's per-function hook profile ONCE (the CRAP path keys findings by
6039/// `(line, col)`, so the profile must be addressable the same way). Frames with
6040/// no attributed component-scope hook are omitted; non-React modules contribute
6041/// nothing.
6042fn build_hook_profiles_by_position<'a>(
6043    modules: &'a [crate::extract::ModuleInfo],
6044    file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
6045) -> rustc_hash::FxHashMap<
6046    &'a std::path::Path,
6047    rustc_hash::FxHashMap<(u32, u32), fallow_output::ReactHookProfile>,
6048> {
6049    let mut by_pos: rustc_hash::FxHashMap<
6050        &'a std::path::Path,
6051        rustc_hash::FxHashMap<(u32, u32), fallow_output::ReactHookProfile>,
6052    > = rustc_hash::FxHashMap::default();
6053    for module in modules {
6054        let Some(&path) = file_paths.get(&module.file_id) else {
6055            continue;
6056        };
6057        let profiles = react_hooks::build_module_hook_profiles(module);
6058        let mut frame_profiles = rustc_hash::FxHashMap::default();
6059        for (fc, profile) in module.complexity.iter().zip(profiles) {
6060            if let Some(profile) = profile {
6061                frame_profiles.insert((fc.line, fc.col), profile);
6062            }
6063        }
6064        if !frame_profiles.is_empty() {
6065            by_pos.insert(path.as_path(), frame_profiles);
6066        }
6067    }
6068    by_pos
6069}
6070
6071fn build_complexity_suppressions_by_path<'a>(
6072    modules: &'a [crate::extract::ModuleInfo],
6073    file_paths: &'a rustc_hash::FxHashMap<crate::discover::FileId, &'a std::path::PathBuf>,
6074) -> rustc_hash::FxHashMap<&'a std::path::Path, &'a Vec<crate::suppress::Suppression>> {
6075    modules
6076        .iter()
6077        .filter_map(|module| {
6078            file_paths
6079                .get(&module.file_id)
6080                .map(|path| (path.as_path(), &module.suppressions))
6081        })
6082        .collect()
6083}
6084
6085fn crap_path_in_scope(path: &std::path::Path, input: &CrapFindingMergeInput<'_>) -> bool {
6086    let relative = path.strip_prefix(input.config_root).unwrap_or(path);
6087    if input.ignore_set.is_match(relative) {
6088        return false;
6089    }
6090    if let Some(changed) = input.changed_files
6091        && !changed.contains(path)
6092    {
6093        return false;
6094    }
6095    if let Some(ws) = input.ws_roots
6096        && !ws.iter().any(|r| path.starts_with(r))
6097    {
6098        return false;
6099    }
6100    true
6101}
6102
6103fn crap_is_suppressed(
6104    path: &std::path::Path,
6105    pf: &scoring::PerFunctionCrap,
6106    suppressions_by_path: &rustc_hash::FxHashMap<
6107        &std::path::Path,
6108        &Vec<crate::suppress::Suppression>,
6109    >,
6110) -> bool {
6111    suppressions_by_path.get(path).is_some_and(|sups| {
6112        crate::suppress::is_suppressed(sups, pf.line, crate::suppress::IssueKind::Complexity)
6113    })
6114}
6115
6116fn merge_existing_crap_finding(
6117    finding: &mut ComplexityViolation,
6118    path: &std::path::Path,
6119    pf: &scoring::PerFunctionCrap,
6120    input: &CrapFindingMergeInput<'_>,
6121    applied_thresholds: AppliedHealthThresholds,
6122) {
6123    finding.crap = Some(pf.crap);
6124    finding.coverage_pct = pf.coverage_pct;
6125    finding.coverage_tier = Some(pf.coverage_tier);
6126    finding.coverage_source = Some(pf.coverage_source);
6127    finding.inherited_from =
6128        inherited_from_for(pf.coverage_source, path, input.template_inherit_provenance);
6129    let exceeds_cyclomatic = finding.exceeded.includes_cyclomatic();
6130    let exceeds_cognitive = finding.exceeded.includes_cognitive();
6131    finding.exceeded = ExceededThreshold::from_bools(exceeds_cyclomatic, exceeds_cognitive, true);
6132    if applied_thresholds.override_index.is_some() {
6133        finding.effective_thresholds = Some(applied_thresholds.effective);
6134        finding.threshold_source = Some(fallow_output::ThresholdSource::Override);
6135    }
6136    finding.severity = compute_finding_severity(
6137        finding.cognitive,
6138        finding.cyclomatic,
6139        Some(pf.crap),
6140        DEFAULT_COGNITIVE_HIGH,
6141        DEFAULT_COGNITIVE_CRITICAL,
6142        DEFAULT_CYCLOMATIC_HIGH,
6143        DEFAULT_CYCLOMATIC_CRITICAL,
6144    );
6145}
6146
6147fn new_crap_finding(
6148    path: &std::path::Path,
6149    pf: &scoring::PerFunctionCrap,
6150    fc: &fallow_types::extract::FunctionComplexity,
6151    hook_profile: Option<fallow_output::ReactHookProfile>,
6152    input: &CrapFindingMergeInput<'_>,
6153    applied_thresholds: AppliedHealthThresholds,
6154) -> ComplexityViolation {
6155    let exceeds_cyclomatic = fc.cyclomatic > applied_thresholds.effective.max_cyclomatic;
6156    let exceeds_cognitive = fc.cognitive > applied_thresholds.effective.max_cognitive;
6157    ComplexityViolation {
6158        path: path.to_path_buf(),
6159        name: fc.name.clone(),
6160        line: fc.line,
6161        col: fc.col,
6162        cyclomatic: fc.cyclomatic,
6163        cognitive: fc.cognitive,
6164        line_count: fc.line_count,
6165        param_count: fc.param_count,
6166        react_hook_count: fc.react_hook_count,
6167        react_jsx_max_depth: fc.react_jsx_max_depth,
6168        react_prop_count: fc.react_prop_count,
6169        react_hook_profile: hook_profile,
6170        exceeded: ExceededThreshold::from_bools(exceeds_cyclomatic, exceeds_cognitive, true),
6171        severity: compute_finding_severity(
6172            fc.cognitive,
6173            fc.cyclomatic,
6174            Some(pf.crap),
6175            DEFAULT_COGNITIVE_HIGH,
6176            DEFAULT_COGNITIVE_CRITICAL,
6177            DEFAULT_CYCLOMATIC_HIGH,
6178            DEFAULT_CYCLOMATIC_CRITICAL,
6179        ),
6180        crap: Some(pf.crap),
6181        coverage_pct: pf.coverage_pct,
6182        coverage_tier: Some(pf.coverage_tier),
6183        coverage_source: Some(pf.coverage_source),
6184        inherited_from: inherited_from_for(
6185            pf.coverage_source,
6186            path,
6187            input.template_inherit_provenance,
6188        ),
6189        component_rollup: None,
6190        contributions: contributions_for(input.complexity_breakdown, fc),
6191        effective_thresholds: applied_thresholds
6192            .override_index
6193            .map(|_| applied_thresholds.effective),
6194        threshold_source: applied_thresholds
6195            .override_index
6196            .map(|_| fallow_output::ThresholdSource::Override),
6197    }
6198}
6199
6200/// Synthesise per-Angular-component rollup findings.
6201///
6202/// For each Angular component that has both at least one class-function
6203/// finding above threshold AND a synthetic `<template>` finding, emit a new
6204/// `<component>` `ComplexityViolation` whose `cyclomatic` / `cognitive` totals are
6205/// `max(class) + template`. The rollup is anchored at the worst class
6206/// function's `(path, line, col)` so an existing
6207/// `// fallow-ignore-next-line complexity` placed above that function (or
6208/// the `@Component` decorator on inline-template components) continues to
6209/// hide both the per-function finding AND the rollup. Per-function and
6210/// per-`<template>` findings are NOT removed; the rollup is strictly
6211/// additive, with [`ComponentRollup`] carrying the breakdown.
6212///
6213/// Component-owner resolution has two branches:
6214/// - `<template>` finding on an `.html` file: look up the owner `.ts` in
6215///   the inverse-`templateUrl` provenance map populated by
6216///   `scoring::build_template_inherit_contexts` (the same walker that drives
6217///   `coverage_source: "estimated_component_inherited"` for CRAP).
6218/// - `<template>` finding on a `.ts` / `.tsx` / `.mts` / `.cts` file
6219///   (inline `@Component({ template: \`...\` })` literals): the owner IS
6220///   the file (`template_complexity.rs` remaps inline-template line/col
6221///   onto the decorator on the same `.ts`).
6222///
6223/// "Class function" is approximated as any finding whose name contains a
6224/// `.` (the `ClassName.methodName` shape `complexity.rs` emits for class
6225/// methods). Free functions and anonymous arrows do not participate in
6226/// rollups; only methods owned by a class do.
6227///
6228/// A `.ts` file carrying TWO synthetic `<template>` findings is treated
6229/// defensively: rollups are skipped (a `.ts` with multiple `@Component`
6230/// decorators would need AST-level class attribution to map each template
6231/// to its owning class, which is out of scope for the first cut). Fallow
6232/// emits a single rollup per owner per pass.
6233///
6234/// `[`ComponentRollup`]`: fallow_output::ComponentRollup
6235fn append_component_rollup_findings(
6236    findings: &mut Vec<fallow_output::ComplexityViolation>,
6237    template_owner_lookup: Option<&rustc_hash::FxHashMap<std::path::PathBuf, std::path::PathBuf>>,
6238    max_cyclomatic: u16,
6239    max_cognitive: u16,
6240) {
6241    use fallow_output::ComplexityViolation;
6242
6243    let mut by_owner: rustc_hash::FxHashMap<std::path::PathBuf, (Vec<usize>, Vec<usize>)> =
6244        rustc_hash::FxHashMap::default();
6245    for (idx, f) in findings.iter().enumerate() {
6246        if f.name == "<template>" {
6247            if let Some(owner) = component_template_owner(f, template_owner_lookup) {
6248                by_owner.entry(owner).or_default().1.push(idx);
6249            }
6250        } else if is_component_class_finding(f) {
6251            by_owner.entry(f.path.clone()).or_default().0.push(idx);
6252        }
6253    }
6254
6255    let mut to_push: Vec<ComplexityViolation> = Vec::new();
6256    for (owner, (class_idxs, template_idxs)) in by_owner {
6257        if class_idxs.is_empty() || template_idxs.is_empty() {
6258            continue;
6259        }
6260        if template_idxs.len() > 1 {
6261            continue;
6262        }
6263        let template = &findings[template_idxs[0]];
6264        let Some(worst_idx) = class_idxs
6265            .iter()
6266            .copied()
6267            .max_by_key(|&i| findings[i].cyclomatic)
6268        else {
6269            continue;
6270        };
6271        let worst = &findings[worst_idx];
6272        if let Some(rollup) =
6273            build_component_rollup(owner, worst, template, max_cyclomatic, max_cognitive)
6274        {
6275            to_push.push(rollup);
6276        }
6277    }
6278    findings.extend(to_push);
6279}
6280
6281fn component_template_owner(
6282    finding: &fallow_output::ComplexityViolation,
6283    template_owner_lookup: Option<&rustc_hash::FxHashMap<std::path::PathBuf, std::path::PathBuf>>,
6284) -> Option<std::path::PathBuf> {
6285    let ext = finding
6286        .path
6287        .extension()
6288        .and_then(|e| e.to_str())
6289        .map(str::to_ascii_lowercase);
6290    match ext.as_deref() {
6291        Some("html") => template_owner_lookup
6292            .and_then(|m| m.get(&finding.path))
6293            .cloned(),
6294        Some("ts" | "tsx" | "mts" | "cts") => Some(finding.path.clone()),
6295        _ => None,
6296    }
6297}
6298
6299fn is_component_class_finding(finding: &fallow_output::ComplexityViolation) -> bool {
6300    finding.name != "<component>"
6301        && finding
6302            .path
6303            .extension()
6304            .and_then(|e| e.to_str())
6305            .is_some_and(|ext| {
6306                matches!(
6307                    ext.to_ascii_lowercase().as_str(),
6308                    "ts" | "tsx" | "mts" | "cts"
6309                )
6310            })
6311}
6312
6313/// The rolled-up cyclomatic / cognitive totals for a component (worst frame plus
6314/// its template) and whether each total exceeds its threshold.
6315struct ComponentRollupTotals {
6316    rollup_cyc: u16,
6317    rollup_cog: u16,
6318    exceeds_cyclomatic: bool,
6319    exceeds_cognitive: bool,
6320}
6321
6322/// Assemble the synthetic `<component>` rollup finding from the precomputed
6323/// totals, the worst class frame, and its template frame.
6324fn make_component_rollup_violation(
6325    owner: std::path::PathBuf,
6326    worst: &fallow_output::ComplexityViolation,
6327    template: &fallow_output::ComplexityViolation,
6328    totals: &ComponentRollupTotals,
6329) -> fallow_output::ComplexityViolation {
6330    use fallow_output::{ComponentRollup, ExceededThreshold};
6331
6332    let component = owner.file_stem().map_or_else(
6333        || "<unknown-component>".to_string(),
6334        |stem| stem.to_string_lossy().into_owned(),
6335    );
6336    fallow_output::ComplexityViolation {
6337        path: owner,
6338        name: "<component>".to_string(),
6339        line: worst.line,
6340        col: worst.col,
6341        cyclomatic: totals.rollup_cyc,
6342        cognitive: totals.rollup_cog,
6343        line_count: worst.line_count.saturating_add(template.line_count),
6344        param_count: 0,
6345        exceeded: ExceededThreshold::from_bools(
6346            totals.exceeds_cyclomatic,
6347            totals.exceeds_cognitive,
6348            false,
6349        ),
6350        severity: compute_finding_severity(
6351            totals.rollup_cog,
6352            totals.rollup_cyc,
6353            None,
6354            DEFAULT_COGNITIVE_HIGH,
6355            DEFAULT_COGNITIVE_CRITICAL,
6356            DEFAULT_CYCLOMATIC_HIGH,
6357            DEFAULT_CYCLOMATIC_CRITICAL,
6358        ),
6359        crap: None,
6360        coverage_pct: None,
6361        coverage_tier: None,
6362        coverage_source: None,
6363        inherited_from: None,
6364        react_hook_count: 0,
6365        react_jsx_max_depth: 0,
6366        react_prop_count: 0,
6367        react_hook_profile: None,
6368        component_rollup: Some(ComponentRollup {
6369            component,
6370            class_worst_function: worst.name.clone(),
6371            class_cyclomatic: worst.cyclomatic,
6372            class_cognitive: worst.cognitive,
6373            template_path: template.path.clone(),
6374            template_cyclomatic: template.cyclomatic,
6375            template_cognitive: template.cognitive,
6376        }),
6377        contributions: Vec::new(),
6378        effective_thresholds: None,
6379        threshold_source: None,
6380    }
6381}
6382
6383fn build_component_rollup(
6384    owner: std::path::PathBuf,
6385    worst: &fallow_output::ComplexityViolation,
6386    template: &fallow_output::ComplexityViolation,
6387    max_cyclomatic: u16,
6388    max_cognitive: u16,
6389) -> Option<fallow_output::ComplexityViolation> {
6390    let rollup_cyc = worst.cyclomatic.saturating_add(template.cyclomatic);
6391    let rollup_cog = worst.cognitive.saturating_add(template.cognitive);
6392    let exceeds_cyclomatic = rollup_cyc > max_cyclomatic;
6393    let exceeds_cognitive = rollup_cog > max_cognitive;
6394    if !exceeds_cyclomatic && !exceeds_cognitive {
6395        return None;
6396    }
6397
6398    let totals = ComponentRollupTotals {
6399        rollup_cyc,
6400        rollup_cog,
6401        exceeds_cyclomatic,
6402        exceeds_cognitive,
6403    };
6404    Some(make_component_rollup_violation(
6405        owner, worst, template, &totals,
6406    ))
6407}
6408
6409/// Resolve the `inherited_from` provenance path for a CRAP finding.
6410///
6411/// Returns `Some(owner_path)` only for the
6412/// `CoverageSource::EstimatedComponentInherited` variant, so the field stays
6413/// absent on every Istanbul / regular-estimated row. Pairs with the
6414/// `coverage_source` discriminator: any finding carrying
6415/// `estimated_component_inherited` also carries `inherited_from`, and vice
6416/// versa.
6417fn inherited_from_for(
6418    source: fallow_output::CoverageSource,
6419    template_path: &std::path::Path,
6420    template_inherit_provenance: &rustc_hash::FxHashMap<std::path::PathBuf, std::path::PathBuf>,
6421) -> Option<std::path::PathBuf> {
6422    if matches!(
6423        source,
6424        fallow_output::CoverageSource::EstimatedComponentInherited
6425    ) {
6426        template_inherit_provenance.get(template_path).cloned()
6427    } else {
6428        None
6429    }
6430}
6431
6432struct HealthBaselineSaveInput<'a> {
6433    save_path: &'a std::path::Path,
6434    findings: &'a [ComplexityViolation],
6435    runtime_coverage_findings: &'a [fallow_output::RuntimeCoverageFinding],
6436    targets: &'a [RefactoringTarget],
6437    config_root: &'a std::path::Path,
6438    quiet: bool,
6439    output: OutputFormat,
6440}
6441
6442/// Save health baseline to disk.
6443fn save_health_baseline(input: &HealthBaselineSaveInput<'_>) -> Result<(), ExitCode> {
6444    let HealthBaselineSaveInput {
6445        save_path,
6446        findings,
6447        runtime_coverage_findings,
6448        targets,
6449        config_root,
6450        quiet,
6451        output,
6452    } = *input;
6453    let baseline = HealthBaselineData::from_findings(
6454        findings,
6455        runtime_coverage_findings,
6456        targets,
6457        config_root,
6458    );
6459    match serde_json::to_string_pretty(&baseline) {
6460        Ok(json) => {
6461            if let Some(parent) = save_path.parent()
6462                && !parent.as_os_str().is_empty()
6463                && let Err(e) = std::fs::create_dir_all(parent)
6464            {
6465                return Err(emit_error(
6466                    &format!("failed to create health baseline directory: {e}"),
6467                    2,
6468                    output,
6469                ));
6470            }
6471            if let Err(e) = std::fs::write(save_path, json) {
6472                return Err(emit_error(
6473                    &format!("failed to save health baseline: {e}"),
6474                    2,
6475                    output,
6476                ));
6477            }
6478            if !quiet {
6479                eprintln!("Saved health baseline to {}", save_path.display());
6480            }
6481            Ok(())
6482        }
6483        Err(e) => Err(emit_error(
6484            &format!("failed to serialize health baseline: {e}"),
6485            2,
6486            output,
6487        )),
6488    }
6489}
6490
6491/// Load and apply a health baseline, filtering findings to show only new ones.
6492fn load_health_baseline(
6493    baseline_path: &std::path::Path,
6494    findings: &mut Vec<ComplexityViolation>,
6495    root: &std::path::Path,
6496    quiet: bool,
6497    output: OutputFormat,
6498) -> Result<HealthBaselineData, ExitCode> {
6499    let json = std::fs::read_to_string(baseline_path)
6500        .map_err(|e| emit_error(&format!("failed to read health baseline: {e}"), 2, output))?;
6501    let baseline: HealthBaselineData = serde_json::from_str(&json)
6502        .map_err(|e| emit_error(&format!("failed to parse health baseline: {e}"), 2, output))?;
6503    let baseline_entries = baseline.finding_entry_count();
6504    let before = findings.len();
6505    let overlap_entries = baseline.overlap_entry_count(findings, root);
6506    *findings = filter_new_health_findings(std::mem::take(findings), &baseline, root);
6507    if !quiet {
6508        eprintln!(
6509            "Comparing against health baseline: {}",
6510            baseline_path.display()
6511        );
6512    }
6513    if baseline_entries > 0 && before > 0 && overlap_entries == 0 && !quiet {
6514        eprintln!(
6515            "Warning: health baseline has {baseline_entries} entries but matched \
6516             0 current findings. Your paths may have changed, or the baseline \
6517             was saved on a different machine. Re-save with: \
6518             --save-baseline {}",
6519            baseline_path.display(),
6520        );
6521    }
6522    Ok(baseline)
6523}
6524
6525#[cfg(test)]
6526mod tests {
6527    use super::*;
6528    use crate::extract::ModuleInfo;
6529    use fallow_types::discover::FileId;
6530    use fallow_types::extract::FunctionComplexity;
6531    use rustc_hash::{FxHashMap, FxHashSet};
6532    use std::path::{Path, PathBuf};
6533
6534    /// Build a minimal `ModuleInfo` with only the fields `collect_findings` needs.
6535    fn make_module(file_id: FileId, complexity: Vec<FunctionComplexity>) -> ModuleInfo {
6536        ModuleInfo {
6537            file_id,
6538            exports: vec![],
6539            imports: vec![],
6540            re_exports: vec![],
6541            dynamic_imports: vec![],
6542            dynamic_import_patterns: vec![],
6543            require_calls: vec![],
6544            package_path_references: Box::default(),
6545            member_accesses: vec![],
6546            semantic_facts: Box::default(),
6547            whole_object_uses: Box::default(),
6548            has_cjs_exports: false,
6549            has_angular_component_template_url: false,
6550            content_hash: 0,
6551            suppressions: vec![],
6552            unknown_suppression_kinds: vec![],
6553            unused_import_bindings: vec![],
6554            type_referenced_import_bindings: vec![],
6555            value_referenced_import_bindings: vec![],
6556            line_offsets: vec![0],
6557            complexity,
6558            flag_uses: vec![],
6559            class_heritage: vec![],
6560            exported_factory_returns: Box::default(),
6561            injection_tokens: vec![],
6562            local_type_declarations: Vec::new(),
6563            public_signature_type_references: Vec::new(),
6564            namespace_object_aliases: Vec::new(),
6565            iconify_prefixes: Vec::new(),
6566            iconify_icon_names: Vec::new(),
6567            auto_import_candidates: Vec::new(),
6568            directives: Vec::new(),
6569            client_only_dynamic_import_spans: Vec::new(),
6570            security_sinks: Vec::new(),
6571            security_sinks_skipped: 0,
6572            security_unresolved_callee_sites: Vec::new(),
6573            tainted_bindings: Vec::new(),
6574            sanitized_sink_args: Vec::new(),
6575            security_control_sites: Vec::new(),
6576            callee_uses: Vec::new(),
6577            misplaced_directives: Vec::new(),
6578            inline_server_action_exports: Vec::new(),
6579            di_key_sites: Vec::new(),
6580            has_dynamic_provide: false,
6581            referenced_import_bindings: Vec::new(),
6582            component_props: Vec::new(),
6583            has_props_attrs_fallthrough: false,
6584            has_define_expose: false,
6585            has_define_model: false,
6586            has_unharvestable_props: false,
6587            component_emits: Vec::new(),
6588            angular_inputs: Vec::new(),
6589            angular_outputs: Vec::new(),
6590            has_unharvestable_emits: false,
6591            has_dynamic_emit: false,
6592            has_emit_whole_object_use: false,
6593            load_return_keys: Vec::new(),
6594            has_unharvestable_load: false,
6595            has_load_data_whole_use: false,
6596            has_page_data_store_whole_use: false,
6597            component_functions: Vec::new(),
6598            react_props: Vec::new(),
6599            hook_uses: Vec::new(),
6600            render_edges: Vec::new(),
6601            svelte_dispatched_events: Vec::new(),
6602            svelte_listened_events: Vec::new(),
6603            angular_component_selectors: Vec::new(),
6604            registered_custom_elements: Vec::new(),
6605            used_custom_element_tags: Vec::new(),
6606            angular_used_selectors: Vec::new(),
6607            angular_entry_component_refs: Vec::new(),
6608            has_dynamic_component_render: false,
6609            has_dynamic_dispatch: false,
6610        }
6611    }
6612
6613    fn make_fc(name: &str, cyclomatic: u16, cognitive: u16, line_count: u32) -> FunctionComplexity {
6614        FunctionComplexity {
6615            name: name.to_string(),
6616            line: 1,
6617            col: 0,
6618            cyclomatic,
6619            cognitive,
6620            line_count,
6621            param_count: 0,
6622            react_hook_count: 0,
6623            react_jsx_max_depth: 0,
6624            react_prop_count: 0,
6625            source_hash: None,
6626            contributions: Vec::new(),
6627        }
6628    }
6629
6630    fn make_fc_with_contributions(
6631        name: &str,
6632        cyclomatic: u16,
6633        cognitive: u16,
6634    ) -> FunctionComplexity {
6635        use fallow_types::extract::{
6636            ComplexityContribution, ComplexityContributionKind, ComplexityMetric,
6637        };
6638        let mut fc = make_fc(name, cyclomatic, cognitive, 50);
6639        fc.contributions = vec![ComplexityContribution {
6640            line: 2,
6641            col: 4,
6642            metric: ComplexityMetric::Cyclomatic,
6643            kind: ComplexityContributionKind::If,
6644            weight: 1,
6645            nesting: 0,
6646        }];
6647        fc
6648    }
6649
6650    #[test]
6651    fn collect_findings_omits_contributions_without_breakdown_flag() {
6652        let path = PathBuf::from("/project/src/a.ts");
6653        let modules = vec![make_module(
6654            FileId(0),
6655            vec![make_fc_with_contributions("complexFn", 25, 5)],
6656        )];
6657        let mut file_paths = FxHashMap::default();
6658        file_paths.insert(FileId(0), &path);
6659        let (findings, _, _) = collect_findings(
6660            &modules,
6661            &file_paths,
6662            Path::new("/project"),
6663            &globset::GlobSet::empty(),
6664            None,
6665            None,
6666            20,
6667            15,
6668            false,
6669        );
6670        assert_eq!(findings.len(), 1);
6671        assert!(
6672            findings[0].contributions.is_empty(),
6673            "contributions must be omitted without the breakdown flag"
6674        );
6675    }
6676
6677    #[test]
6678    fn collect_findings_includes_contributions_with_breakdown_flag() {
6679        let path = PathBuf::from("/project/src/a.ts");
6680        let modules = vec![make_module(
6681            FileId(0),
6682            vec![make_fc_with_contributions("complexFn", 25, 5)],
6683        )];
6684        let mut file_paths = FxHashMap::default();
6685        file_paths.insert(FileId(0), &path);
6686        let (findings, _, _) = collect_findings(
6687            &modules,
6688            &file_paths,
6689            Path::new("/project"),
6690            &globset::GlobSet::empty(),
6691            None,
6692            None,
6693            20,
6694            15,
6695            true,
6696        );
6697        assert_eq!(findings.len(), 1);
6698        assert_eq!(
6699            findings[0].contributions.len(),
6700            1,
6701            "contributions must flow through when the breakdown flag is set"
6702        );
6703    }
6704
6705    fn threshold_resolver(
6706        overrides: &[fallow_config::HealthThresholdOverride],
6707    ) -> ThresholdOverrideResolver {
6708        ThresholdOverrideResolver::new(
6709            overrides,
6710            GlobalHealthThresholds {
6711                cyclomatic: 20,
6712                cognitive: 15,
6713                crap: 30.0,
6714            },
6715        )
6716    }
6717
6718    #[test]
6719    fn collect_findings_uses_threshold_override_as_local_ceiling() {
6720        let path = PathBuf::from("/project/src/a.ts");
6721        let modules = vec![make_module(
6722            FileId(0),
6723            vec![make_fc("complexFn", 25, 20, 50)],
6724        )];
6725        let mut file_paths = FxHashMap::default();
6726        file_paths.insert(FileId(0), &path);
6727        let resolver = threshold_resolver(&[fallow_config::HealthThresholdOverride {
6728            files: vec!["src/a.ts".to_string()],
6729            functions: vec!["complexFn".to_string()],
6730            max_cyclomatic: Some(30),
6731            max_cognitive: Some(25),
6732            max_crap: None,
6733            reason: Some("approved assembly".to_string()),
6734        }]);
6735        let mut tracker = ThresholdOverrideStateTracker::default();
6736
6737        let mut input = CollectFindingsInput {
6738            modules: &modules,
6739            file_paths: &file_paths,
6740            config_root: Path::new("/project"),
6741            ignore_set: &globset::GlobSet::empty(),
6742            changed_files: None,
6743            ws_roots: None,
6744            threshold_resolver: &resolver,
6745            threshold_state_tracker: &mut tracker,
6746            complexity_breakdown: false,
6747        };
6748        let (findings, _, _) = collect_findings_with_resolver(&mut input);
6749
6750        assert!(findings.is_empty());
6751        let states = tracker.into_states();
6752        assert_eq!(states.len(), 1);
6753        assert!(matches!(
6754            states[0].status,
6755            fallow_output::ThresholdOverrideStatus::Active
6756        ));
6757    }
6758
6759    #[test]
6760    fn collect_findings_reports_when_local_ceiling_is_exceeded() {
6761        let path = PathBuf::from("/project/src/a.ts");
6762        let modules = vec![make_module(
6763            FileId(0),
6764            vec![make_fc("complexFn", 31, 20, 50)],
6765        )];
6766        let mut file_paths = FxHashMap::default();
6767        file_paths.insert(FileId(0), &path);
6768        let resolver = threshold_resolver(&[fallow_config::HealthThresholdOverride {
6769            files: vec!["src/a.ts".to_string()],
6770            functions: vec!["complexFn".to_string()],
6771            max_cyclomatic: Some(30),
6772            max_cognitive: Some(25),
6773            max_crap: None,
6774            reason: None,
6775        }]);
6776        let mut tracker = ThresholdOverrideStateTracker::default();
6777
6778        let mut input = CollectFindingsInput {
6779            modules: &modules,
6780            file_paths: &file_paths,
6781            config_root: Path::new("/project"),
6782            ignore_set: &globset::GlobSet::empty(),
6783            changed_files: None,
6784            ws_roots: None,
6785            threshold_resolver: &resolver,
6786            threshold_state_tracker: &mut tracker,
6787            complexity_breakdown: false,
6788        };
6789        let (findings, _, _) = collect_findings_with_resolver(&mut input);
6790
6791        assert_eq!(findings.len(), 1);
6792        assert_eq!(findings[0].effective_thresholds.unwrap().max_cyclomatic, 30);
6793        assert!(matches!(
6794            findings[0].threshold_source,
6795            Some(fallow_output::ThresholdSource::Override)
6796        ));
6797    }
6798
6799    #[test]
6800    fn collect_findings_reports_stale_override_when_under_global_thresholds() {
6801        let path = PathBuf::from("/project/src/a.ts");
6802        let modules = vec![make_module(
6803            FileId(0),
6804            vec![make_fc("complexFn", 10, 8, 20)],
6805        )];
6806        let mut file_paths = FxHashMap::default();
6807        file_paths.insert(FileId(0), &path);
6808        let resolver = threshold_resolver(&[fallow_config::HealthThresholdOverride {
6809            files: vec!["src/a.ts".to_string()],
6810            functions: vec!["complexFn".to_string()],
6811            max_cyclomatic: Some(30),
6812            max_cognitive: None,
6813            max_crap: None,
6814            reason: None,
6815        }]);
6816        let mut tracker = ThresholdOverrideStateTracker::default();
6817
6818        let mut input = CollectFindingsInput {
6819            modules: &modules,
6820            file_paths: &file_paths,
6821            config_root: Path::new("/project"),
6822            ignore_set: &globset::GlobSet::empty(),
6823            changed_files: None,
6824            ws_roots: None,
6825            threshold_resolver: &resolver,
6826            threshold_state_tracker: &mut tracker,
6827            complexity_breakdown: false,
6828        };
6829        let (findings, _, _) = collect_findings_with_resolver(&mut input);
6830
6831        assert!(findings.is_empty());
6832        let states = tracker.into_states();
6833        assert_eq!(states.len(), 1);
6834        assert!(matches!(
6835            states[0].status,
6836            fallow_output::ThresholdOverrideStatus::Stale
6837        ));
6838    }
6839
6840    #[test]
6841    fn threshold_override_tracker_reports_no_match_only_when_requested() {
6842        let resolver = threshold_resolver(&[fallow_config::HealthThresholdOverride {
6843            files: vec!["src/missing.ts".to_string()],
6844            functions: vec!["missingFn".to_string()],
6845            max_cyclomatic: Some(30),
6846            max_cognitive: None,
6847            max_crap: None,
6848            reason: None,
6849        }]);
6850        let mut tracker = ThresholdOverrideStateTracker::default();
6851        tracker.record_no_match_entries(&resolver, false);
6852        assert!(tracker.into_states().is_empty());
6853
6854        let mut tracker = ThresholdOverrideStateTracker::default();
6855        tracker.record_no_match_entries(&resolver, true);
6856        let states = tracker.into_states();
6857        assert_eq!(states.len(), 1);
6858        assert!(matches!(
6859            states[0].status,
6860            fallow_output::ThresholdOverrideStatus::NoMatch
6861        ));
6862    }
6863
6864    #[test]
6865    fn build_ignore_set_empty_patterns() {
6866        let set = build_ignore_set(&[]);
6867        assert!(set.is_empty());
6868    }
6869
6870    #[test]
6871    fn build_ignore_set_matches_glob() {
6872        let patterns = vec!["src/generated/**".to_string()];
6873        let set = build_ignore_set(&patterns);
6874        assert!(set.is_match(Path::new("src/generated/types.ts")));
6875        assert!(!set.is_match(Path::new("src/utils.ts")));
6876    }
6877
6878    #[test]
6879    fn build_ignore_set_multiple_patterns() {
6880        let patterns = vec!["*.test.ts".to_string(), "dist/**".to_string()];
6881        let set = build_ignore_set(&patterns);
6882        assert!(set.is_match(Path::new("foo.test.ts")));
6883        assert!(set.is_match(Path::new("dist/index.js")));
6884        assert!(!set.is_match(Path::new("src/index.ts")));
6885    }
6886
6887    #[test]
6888    #[should_panic(expected = "validated at config load time")]
6889    fn build_ignore_set_panics_on_unvalidated_invalid_pattern() {
6890        let patterns = vec!["[invalid".to_string(), "*.js".to_string()];
6891        let _ = build_ignore_set(&patterns);
6892    }
6893
6894    fn make_finding(name: &str, exceeded: ExceededThreshold) -> ComplexityViolation {
6895        ComplexityViolation {
6896            path: PathBuf::from("/project/src/a.ts"),
6897            name: name.to_string(),
6898            line: 1,
6899            col: 0,
6900            cyclomatic: match exceeded {
6901                ExceededThreshold::Cyclomatic
6902                | ExceededThreshold::Both
6903                | ExceededThreshold::CyclomaticCrap
6904                | ExceededThreshold::All => 25,
6905                _ => 8,
6906            },
6907            cognitive: match exceeded {
6908                ExceededThreshold::Cognitive
6909                | ExceededThreshold::Both
6910                | ExceededThreshold::CognitiveCrap
6911                | ExceededThreshold::All => 20,
6912                _ => 5,
6913            },
6914            line_count: 10,
6915            param_count: 0,
6916            react_hook_count: 0,
6917            react_jsx_max_depth: 0,
6918            react_prop_count: 0,
6919            react_hook_profile: None,
6920            exceeded,
6921            severity: FindingSeverity::Moderate,
6922            crap: exceeded.includes_crap().then_some(30.0),
6923            coverage_pct: None,
6924            coverage_tier: None,
6925            coverage_source: None,
6926            inherited_from: None,
6927            component_rollup: None,
6928            contributions: Vec::new(),
6929            effective_thresholds: None,
6930            threshold_source: None,
6931        }
6932    }
6933
6934    #[test]
6935    fn sort_findings_by_severity_surfaces_crap_before_single_metric_findings() {
6936        let mut findings = vec![
6937            make_finding("cyclomatic", ExceededThreshold::Cyclomatic),
6938            make_finding("cognitive", ExceededThreshold::Cognitive),
6939            make_finding("both", ExceededThreshold::Both),
6940            make_finding("crap", ExceededThreshold::Crap),
6941            make_finding("cyclomatic_crap", ExceededThreshold::CyclomaticCrap),
6942            make_finding("all", ExceededThreshold::All),
6943        ];
6944
6945        sort_findings(&mut findings, HealthSort::Severity);
6946
6947        let names = findings
6948            .iter()
6949            .map(|finding| finding.name.as_str())
6950            .collect::<Vec<_>>();
6951        assert_eq!(
6952            names,
6953            [
6954                "all",
6955                "cyclomatic_crap",
6956                "crap",
6957                "both",
6958                "cyclomatic",
6959                "cognitive",
6960            ]
6961        );
6962    }
6963
6964    #[test]
6965    fn collect_findings_empty_modules() {
6966        let (findings, files, functions) = collect_findings(
6967            &[],
6968            &FxHashMap::default(),
6969            Path::new("/project"),
6970            &globset::GlobSet::empty(),
6971            None,
6972            None,
6973            20,
6974            15,
6975            false,
6976        );
6977        assert!(findings.is_empty());
6978        assert_eq!(files, 0);
6979        assert_eq!(functions, 0);
6980    }
6981
6982    #[test]
6983    fn collect_findings_below_threshold() {
6984        let path = PathBuf::from("/project/src/a.ts");
6985        let modules = vec![make_module(FileId(0), vec![make_fc("doStuff", 5, 3, 10)])];
6986        let mut file_paths = FxHashMap::default();
6987        file_paths.insert(FileId(0), &path);
6988
6989        let (findings, files, functions) = collect_findings(
6990            &modules,
6991            &file_paths,
6992            Path::new("/project"),
6993            &globset::GlobSet::empty(),
6994            None,
6995            None,
6996            20,
6997            15,
6998            false,
6999        );
7000        assert!(findings.is_empty());
7001        assert_eq!(files, 1);
7002        assert_eq!(functions, 1);
7003    }
7004
7005    #[test]
7006    fn collect_findings_exceeds_cyclomatic_only() {
7007        let path = PathBuf::from("/project/src/a.ts");
7008        let modules = vec![make_module(
7009            FileId(0),
7010            vec![make_fc("complexFn", 25, 5, 50)],
7011        )];
7012        let mut file_paths = FxHashMap::default();
7013        file_paths.insert(FileId(0), &path);
7014
7015        let (findings, _, _) = collect_findings(
7016            &modules,
7017            &file_paths,
7018            Path::new("/project"),
7019            &globset::GlobSet::empty(),
7020            None,
7021            None,
7022            20,
7023            15,
7024            false,
7025        );
7026        assert_eq!(findings.len(), 1);
7027        assert_eq!(findings[0].cyclomatic, 25);
7028        assert!(matches!(
7029            findings[0].exceeded,
7030            ExceededThreshold::Cyclomatic
7031        ));
7032    }
7033
7034    #[test]
7035    fn collect_findings_exceeds_cognitive_only() {
7036        let path = PathBuf::from("/project/src/a.ts");
7037        let modules = vec![make_module(FileId(0), vec![make_fc("nestedFn", 5, 20, 30)])];
7038        let mut file_paths = FxHashMap::default();
7039        file_paths.insert(FileId(0), &path);
7040
7041        let (findings, _, _) = collect_findings(
7042            &modules,
7043            &file_paths,
7044            Path::new("/project"),
7045            &globset::GlobSet::empty(),
7046            None,
7047            None,
7048            20,
7049            15,
7050            false,
7051        );
7052        assert_eq!(findings.len(), 1);
7053        assert!(matches!(findings[0].exceeded, ExceededThreshold::Cognitive));
7054    }
7055
7056    #[test]
7057    fn collect_findings_exceeds_both() {
7058        let path = PathBuf::from("/project/src/a.ts");
7059        let modules = vec![make_module(
7060            FileId(0),
7061            vec![make_fc("terribleFn", 25, 20, 100)],
7062        )];
7063        let mut file_paths = FxHashMap::default();
7064        file_paths.insert(FileId(0), &path);
7065
7066        let (findings, _, _) = collect_findings(
7067            &modules,
7068            &file_paths,
7069            Path::new("/project"),
7070            &globset::GlobSet::empty(),
7071            None,
7072            None,
7073            20,
7074            15,
7075            false,
7076        );
7077        assert_eq!(findings.len(), 1);
7078        assert!(matches!(findings[0].exceeded, ExceededThreshold::Both));
7079    }
7080
7081    #[test]
7082    fn collect_findings_multiple_functions_per_file() {
7083        let path = PathBuf::from("/project/src/a.ts");
7084        let modules = vec![make_module(
7085            FileId(0),
7086            vec![
7087                make_fc("ok", 5, 3, 10),
7088                make_fc("bad", 25, 20, 50),
7089                make_fc("also_bad", 21, 5, 30),
7090            ],
7091        )];
7092        let mut file_paths = FxHashMap::default();
7093        file_paths.insert(FileId(0), &path);
7094
7095        let (findings, files, functions) = collect_findings(
7096            &modules,
7097            &file_paths,
7098            Path::new("/project"),
7099            &globset::GlobSet::empty(),
7100            None,
7101            None,
7102            20,
7103            15,
7104            false,
7105        );
7106        assert_eq!(findings.len(), 2);
7107        assert_eq!(files, 1);
7108        assert_eq!(functions, 3);
7109    }
7110
7111    #[test]
7112    fn collect_findings_ignores_matching_files() {
7113        let path = PathBuf::from("/project/src/generated/types.ts");
7114        let modules = vec![make_module(FileId(0), vec![make_fc("genFn", 25, 20, 50)])];
7115        let mut file_paths = FxHashMap::default();
7116        file_paths.insert(FileId(0), &path);
7117
7118        let ignore_set = build_ignore_set(&["src/generated/**".to_string()]);
7119        let (findings, files, _) = collect_findings(
7120            &modules,
7121            &file_paths,
7122            Path::new("/project"),
7123            &ignore_set,
7124            None,
7125            None,
7126            20,
7127            15,
7128            false,
7129        );
7130        assert!(findings.is_empty());
7131        assert_eq!(files, 0);
7132    }
7133
7134    #[test]
7135    fn collect_findings_filters_by_changed_files() {
7136        let path_a = PathBuf::from("/project/src/a.ts");
7137        let path_b = PathBuf::from("/project/src/b.ts");
7138        let modules = vec![
7139            make_module(FileId(0), vec![make_fc("fnA", 25, 20, 50)]),
7140            make_module(FileId(1), vec![make_fc("fnB", 25, 20, 50)]),
7141        ];
7142        let mut file_paths = FxHashMap::default();
7143        file_paths.insert(FileId(0), &path_a);
7144        file_paths.insert(FileId(1), &path_b);
7145
7146        let mut changed = FxHashSet::default();
7147        changed.insert(PathBuf::from("/project/src/a.ts"));
7148
7149        let (findings, files, _) = collect_findings(
7150            &modules,
7151            &file_paths,
7152            Path::new("/project"),
7153            &globset::GlobSet::empty(),
7154            Some(&changed),
7155            None,
7156            20,
7157            15,
7158            false,
7159        );
7160        assert_eq!(findings.len(), 1);
7161        assert_eq!(findings[0].name, "fnA");
7162        assert_eq!(files, 1);
7163    }
7164
7165    fn build_diff(text: &str) -> fallow_output::DiffIndex {
7166        fallow_output::DiffIndex::from_unified_diff(text)
7167    }
7168
7169    #[test]
7170    fn filter_complexity_findings_by_diff_keeps_hotspot_overlapping_diff_line() {
7171        let mut findings = vec![ComplexityViolation {
7172            path: PathBuf::from("/project/src/big.ts"),
7173            name: "wide_fn".into(),
7174            line: 10,
7175            col: 0,
7176            cyclomatic: 30,
7177            cognitive: 30,
7178            line_count: 110,
7179            param_count: 0,
7180            react_hook_count: 0,
7181            react_jsx_max_depth: 0,
7182            react_prop_count: 0,
7183            react_hook_profile: None,
7184            exceeded: ExceededThreshold::Both,
7185            severity: FindingSeverity::High,
7186            crap: None,
7187            coverage_pct: None,
7188            coverage_tier: None,
7189            coverage_source: None,
7190            inherited_from: None,
7191            component_rollup: None,
7192            contributions: Vec::new(),
7193            effective_thresholds: None,
7194            threshold_source: None,
7195        }];
7196        let diff = build_diff(
7197            "diff --git a/src/big.ts b/src/big.ts\n\
7198             --- a/src/big.ts\n\
7199             +++ b/src/big.ts\n\
7200             @@ -114,1 +114,2 @@\n\
7201              ctx\n\
7202             +touched\n",
7203        );
7204        filter_complexity_findings_by_diff(&mut findings, &diff, Path::new("/project"));
7205        assert_eq!(findings.len(), 1);
7206    }
7207
7208    #[test]
7209    fn filter_complexity_findings_by_diff_drops_finding_outside_diff() {
7210        let mut findings = vec![ComplexityViolation {
7211            path: PathBuf::from("/project/src/elsewhere.ts"),
7212            name: "outside".into(),
7213            line: 10,
7214            col: 0,
7215            cyclomatic: 30,
7216            cognitive: 30,
7217            line_count: 5,
7218            param_count: 0,
7219            react_hook_count: 0,
7220            react_jsx_max_depth: 0,
7221            react_prop_count: 0,
7222            react_hook_profile: None,
7223            exceeded: ExceededThreshold::Both,
7224            severity: FindingSeverity::High,
7225            crap: None,
7226            coverage_pct: None,
7227            coverage_tier: None,
7228            coverage_source: None,
7229            inherited_from: None,
7230            component_rollup: None,
7231            contributions: Vec::new(),
7232            effective_thresholds: None,
7233            threshold_source: None,
7234        }];
7235        let diff = build_diff(
7236            "diff --git a/src/big.ts b/src/big.ts\n\
7237             --- a/src/big.ts\n\
7238             +++ b/src/big.ts\n\
7239             @@ -114,1 +114,2 @@\n\
7240              ctx\n\
7241             +touched\n",
7242        );
7243        filter_complexity_findings_by_diff(&mut findings, &diff, Path::new("/project"));
7244        assert!(findings.is_empty());
7245    }
7246
7247    #[test]
7248    fn filter_complexity_findings_by_diff_handles_zero_line_count() {
7249        let mut findings = vec![ComplexityViolation {
7250            path: PathBuf::from("/project/src/a.ts"),
7251            name: "zero_extent".into(),
7252            line: 5,
7253            col: 0,
7254            cyclomatic: 30,
7255            cognitive: 30,
7256            line_count: 0,
7257            param_count: 0,
7258            react_hook_count: 0,
7259            react_jsx_max_depth: 0,
7260            react_prop_count: 0,
7261            react_hook_profile: None,
7262            exceeded: ExceededThreshold::Both,
7263            severity: FindingSeverity::High,
7264            crap: None,
7265            coverage_pct: None,
7266            coverage_tier: None,
7267            coverage_source: None,
7268            inherited_from: None,
7269            component_rollup: None,
7270            contributions: Vec::new(),
7271            effective_thresholds: None,
7272            threshold_source: None,
7273        }];
7274        let diff = build_diff(
7275            "diff --git a/src/a.ts b/src/a.ts\n\
7276             --- a/src/a.ts\n\
7277             +++ b/src/a.ts\n\
7278             @@ -4,1 +4,2 @@\n\
7279              ctx\n\
7280             +touched\n",
7281        );
7282        filter_complexity_findings_by_diff(&mut findings, &diff, Path::new("/project"));
7283        assert_eq!(findings.len(), 1);
7284    }
7285
7286    #[test]
7287    fn filter_hotspots_by_diff_uses_file_level_membership() {
7288        use fallow_output::HotspotEntry;
7289        let mut hotspots = vec![
7290            HotspotEntry {
7291                path: PathBuf::from("/project/src/touched.ts"),
7292                score: 90.0,
7293                commits: 50,
7294                weighted_commits: 25.0,
7295                lines_added: 1000,
7296                lines_deleted: 500,
7297                complexity_density: 0.4,
7298                fan_in: 5,
7299                trend: crate::churn::ChurnTrend::Stable,
7300                ownership: None,
7301                is_test_path: false,
7302            },
7303            HotspotEntry {
7304                path: PathBuf::from("/project/src/untouched.ts"),
7305                score: 90.0,
7306                commits: 50,
7307                weighted_commits: 25.0,
7308                lines_added: 1000,
7309                lines_deleted: 500,
7310                complexity_density: 0.4,
7311                fan_in: 5,
7312                trend: crate::churn::ChurnTrend::Stable,
7313                ownership: None,
7314                is_test_path: false,
7315            },
7316        ];
7317        let diff = build_diff(
7318            "diff --git a/src/touched.ts b/src/touched.ts\n\
7319             --- a/src/touched.ts\n\
7320             +++ b/src/touched.ts\n\
7321             @@ -0,0 +1,1 @@\n\
7322             +new\n",
7323        );
7324        filter_hotspots_by_diff(&mut hotspots, &diff, Path::new("/project"));
7325        assert_eq!(hotspots.len(), 1);
7326        assert_eq!(hotspots[0].path, PathBuf::from("/project/src/touched.ts"));
7327    }
7328
7329    #[test]
7330    fn filter_large_functions_by_diff_uses_range_overlap() {
7331        use fallow_output::LargeFunctionEntry;
7332        let mut entries = vec![
7333            LargeFunctionEntry {
7334                path: PathBuf::from("/project/src/a.ts"),
7335                name: "kept".into(),
7336                line: 10,
7337                line_count: 100,
7338            },
7339            LargeFunctionEntry {
7340                path: PathBuf::from("/project/src/a.ts"),
7341                name: "dropped".into(),
7342                line: 500,
7343                line_count: 100,
7344            },
7345        ];
7346        let diff = build_diff(
7347            "diff --git a/src/a.ts b/src/a.ts\n\
7348             --- a/src/a.ts\n\
7349             +++ b/src/a.ts\n\
7350             @@ -49,1 +49,2 @@\n\
7351              ctx\n\
7352             +touched\n",
7353        );
7354        filter_large_functions_by_diff(&mut entries, &diff, Path::new("/project"));
7355        assert_eq!(entries.len(), 1);
7356        assert_eq!(entries[0].name, "kept");
7357    }
7358
7359    #[test]
7360    fn collect_findings_skips_module_without_path() {
7361        let modules = vec![make_module(FileId(99), vec![make_fc("orphan", 25, 20, 50)])];
7362        let file_paths = FxHashMap::default();
7363
7364        let (findings, files, _) = collect_findings(
7365            &modules,
7366            &file_paths,
7367            Path::new("/project"),
7368            &globset::GlobSet::empty(),
7369            None,
7370            None,
7371            20,
7372            15,
7373            false,
7374        );
7375        assert!(findings.is_empty());
7376        assert_eq!(files, 0);
7377    }
7378
7379    #[test]
7380    fn collect_findings_at_exact_threshold_not_reported() {
7381        let path = PathBuf::from("/project/src/a.ts");
7382        let modules = vec![make_module(
7383            FileId(0),
7384            vec![make_fc("borderline", 20, 15, 20)],
7385        )];
7386        let mut file_paths = FxHashMap::default();
7387        file_paths.insert(FileId(0), &path);
7388
7389        let (findings, _, _) = collect_findings(
7390            &modules,
7391            &file_paths,
7392            Path::new("/project"),
7393            &globset::GlobSet::empty(),
7394            None,
7395            None,
7396            20,
7397            15,
7398            false,
7399        );
7400        assert!(findings.is_empty());
7401    }
7402
7403    #[test]
7404    fn collect_findings_preserves_function_metadata() {
7405        let path = PathBuf::from("/project/src/a.ts");
7406        let modules = vec![make_module(
7407            FileId(0),
7408            vec![FunctionComplexity {
7409                name: "processData".to_string(),
7410                line: 42,
7411                col: 8,
7412                cyclomatic: 25,
7413                cognitive: 18,
7414                line_count: 75,
7415                param_count: 2,
7416                react_hook_count: 0,
7417                react_jsx_max_depth: 0,
7418                react_prop_count: 0,
7419                source_hash: None,
7420                contributions: Vec::new(),
7421            }],
7422        )];
7423        let mut file_paths = FxHashMap::default();
7424        file_paths.insert(FileId(0), &path);
7425
7426        let (findings, _, _) = collect_findings(
7427            &modules,
7428            &file_paths,
7429            Path::new("/project"),
7430            &globset::GlobSet::empty(),
7431            None,
7432            None,
7433            20,
7434            15,
7435            false,
7436        );
7437        assert_eq!(findings.len(), 1);
7438        let f = &findings[0];
7439        assert_eq!(f.name, "processData");
7440        assert_eq!(f.line, 42);
7441        assert_eq!(f.col, 8);
7442        assert_eq!(f.cyclomatic, 25);
7443        assert_eq!(f.cognitive, 18);
7444        assert_eq!(f.line_count, 75);
7445        assert_eq!(f.path, PathBuf::from("/project/src/a.ts"));
7446    }
7447
7448    #[test]
7449    fn merge_crap_findings_disambiguates_same_line_functions() {
7450        let path = PathBuf::from("/project/src/curried.ts");
7451        let outer = FunctionComplexity {
7452            name: "handler".to_string(),
7453            line: 1,
7454            col: 23,
7455            cyclomatic: 1,
7456            cognitive: 0,
7457            line_count: 11,
7458            param_count: 1,
7459            react_hook_count: 0,
7460            react_jsx_max_depth: 0,
7461            react_prop_count: 0,
7462            source_hash: None,
7463            contributions: Vec::new(),
7464        };
7465        let inner = FunctionComplexity {
7466            name: "<arrow>".to_string(),
7467            line: 1,
7468            col: 43,
7469            cyclomatic: 7,
7470            cognitive: 0,
7471            line_count: 10,
7472            param_count: 1,
7473            react_hook_count: 0,
7474            react_jsx_max_depth: 0,
7475            react_prop_count: 0,
7476            source_hash: None,
7477            contributions: Vec::new(),
7478        };
7479        let modules = vec![make_module(FileId(0), vec![inner.clone(), outer.clone()])];
7480        let mut file_paths: FxHashMap<FileId, &PathBuf> = FxHashMap::default();
7481        file_paths.insert(FileId(0), &path);
7482
7483        let mut findings: Vec<ComplexityViolation> = Vec::new();
7484
7485        let mut per_function_crap: FxHashMap<PathBuf, Vec<scoring::PerFunctionCrap>> =
7486            FxHashMap::default();
7487        per_function_crap.insert(
7488            path.clone(),
7489            vec![
7490                scoring::PerFunctionCrap {
7491                    line: inner.line,
7492                    col: inner.col,
7493                    crap: 56.0,
7494                    coverage_pct: None,
7495                    coverage_tier: fallow_output::CoverageTier::None,
7496                    coverage_source: fallow_output::CoverageSource::Estimated,
7497                },
7498                scoring::PerFunctionCrap {
7499                    line: outer.line,
7500                    col: outer.col,
7501                    crap: 2.0,
7502                    coverage_pct: None,
7503                    coverage_tier: fallow_output::CoverageTier::None,
7504                    coverage_source: fallow_output::CoverageSource::Estimated,
7505                },
7506            ],
7507        );
7508
7509        let resolver = threshold_resolver(&[]);
7510        let mut tracker = ThresholdOverrideStateTracker::default();
7511        let mut input = CrapFindingMergeInput {
7512            modules: &modules,
7513            file_paths: &file_paths,
7514            config_root: Path::new("/project"),
7515            ignore_set: &globset::GlobSet::empty(),
7516            changed_files: None,
7517            ws_roots: None,
7518            per_function_crap: &per_function_crap,
7519            template_inherit_provenance: &FxHashMap::default(),
7520            complexity_breakdown: false,
7521            threshold_resolver: &resolver,
7522            threshold_state_tracker: &mut tracker,
7523        };
7524        merge_crap_findings(&mut findings, &mut input);
7525
7526        assert_eq!(
7527            findings.len(),
7528            1,
7529            "expected one CRAP finding for inner arrow"
7530        );
7531        let f = &findings[0];
7532        assert_eq!(f.name, "<arrow>", "name must come from inner arrow");
7533        assert_eq!(f.line, 1);
7534        assert_eq!(f.col, 43, "col must disambiguate same-line arrows");
7535        assert_eq!(f.cyclomatic, 7, "cyclomatic must come from inner arrow");
7536        assert_eq!(f.cognitive, 0);
7537        assert_eq!(
7538            f.crap,
7539            Some(56.0),
7540            "CRAP must match the function it's reported against"
7541        );
7542        let cc = f64::from(f.cyclomatic);
7543        #[expect(
7544            clippy::suboptimal_flops,
7545            reason = "cc * cc + cc matches the CRAP formula specification"
7546        )]
7547        let expected_crap = cc * cc + cc;
7548        assert!(
7549            (f.crap.unwrap() - expected_crap).abs() < 0.01,
7550            "CRAP must be consistent with reported CC: cc={cc}, crap={:?}, expected={expected_crap}",
7551            f.crap,
7552        );
7553    }
7554
7555    #[test]
7556    fn merge_crap_findings_picks_outer_when_outer_exceeds() {
7557        let path = PathBuf::from("/project/src/curried_outer.ts");
7558        let outer = FunctionComplexity {
7559            name: "complex".to_string(),
7560            line: 5,
7561            col: 10,
7562            cyclomatic: 8,
7563            cognitive: 0,
7564            line_count: 20,
7565            param_count: 1,
7566            react_hook_count: 0,
7567            react_jsx_max_depth: 0,
7568            react_prop_count: 0,
7569            source_hash: None,
7570            contributions: Vec::new(),
7571        };
7572        let inner = FunctionComplexity {
7573            name: "<arrow>".to_string(),
7574            line: 5,
7575            col: 30,
7576            cyclomatic: 1,
7577            cognitive: 0,
7578            line_count: 1,
7579            param_count: 1,
7580            react_hook_count: 0,
7581            react_jsx_max_depth: 0,
7582            react_prop_count: 0,
7583            source_hash: None,
7584            contributions: Vec::new(),
7585        };
7586        let modules = vec![make_module(FileId(0), vec![inner.clone(), outer.clone()])];
7587        let mut file_paths: FxHashMap<FileId, &PathBuf> = FxHashMap::default();
7588        file_paths.insert(FileId(0), &path);
7589
7590        let mut findings: Vec<ComplexityViolation> = Vec::new();
7591        let mut per_function_crap: FxHashMap<PathBuf, Vec<scoring::PerFunctionCrap>> =
7592            FxHashMap::default();
7593        per_function_crap.insert(
7594            path.clone(),
7595            vec![
7596                scoring::PerFunctionCrap {
7597                    line: inner.line,
7598                    col: inner.col,
7599                    crap: 2.0,
7600                    coverage_pct: None,
7601                    coverage_tier: fallow_output::CoverageTier::None,
7602                    coverage_source: fallow_output::CoverageSource::Estimated,
7603                },
7604                scoring::PerFunctionCrap {
7605                    line: outer.line,
7606                    col: outer.col,
7607                    crap: 72.0,
7608                    coverage_pct: None,
7609                    coverage_tier: fallow_output::CoverageTier::None,
7610                    coverage_source: fallow_output::CoverageSource::Estimated,
7611                },
7612            ],
7613        );
7614
7615        let resolver = threshold_resolver(&[]);
7616        let mut tracker = ThresholdOverrideStateTracker::default();
7617        let mut input = CrapFindingMergeInput {
7618            modules: &modules,
7619            file_paths: &file_paths,
7620            config_root: Path::new("/project"),
7621            ignore_set: &globset::GlobSet::empty(),
7622            changed_files: None,
7623            ws_roots: None,
7624            per_function_crap: &per_function_crap,
7625            template_inherit_provenance: &FxHashMap::default(),
7626            complexity_breakdown: false,
7627            threshold_resolver: &resolver,
7628            threshold_state_tracker: &mut tracker,
7629        };
7630        merge_crap_findings(&mut findings, &mut input);
7631
7632        assert_eq!(findings.len(), 1);
7633        let f = &findings[0];
7634        assert_eq!(f.name, "complex");
7635        assert_eq!(f.col, 10);
7636        assert_eq!(f.cyclomatic, 8);
7637        assert_eq!(f.crap, Some(72.0));
7638    }
7639
7640    fn fx_summary(
7641        tracked: usize,
7642        hit: usize,
7643        unhit: usize,
7644        untracked: usize,
7645    ) -> fallow_output::RuntimeCoverageSummary {
7646        #[expect(
7647            clippy::cast_precision_loss,
7648            reason = "test fixture totals are tiny, f64 precision is fine"
7649        )]
7650        let coverage_percent = if tracked == 0 {
7651            0.0
7652        } else {
7653            (hit as f64 / tracked as f64) * 100.0
7654        };
7655        fallow_output::RuntimeCoverageSummary {
7656            data_source: fallow_output::RuntimeCoverageDataSource::Local,
7657            last_received_at: None,
7658            functions_tracked: tracked,
7659            functions_hit: hit,
7660            functions_unhit: unhit,
7661            functions_untracked: untracked,
7662            coverage_percent,
7663            trace_count: 512,
7664            period_days: 7,
7665            deployments_seen: 2,
7666            capture_quality: None,
7667        }
7668    }
7669
7670    fn fx_evidence(
7671        static_status: &str,
7672        test_coverage: &str,
7673        v8_tracking: &str,
7674    ) -> fallow_output::RuntimeCoverageEvidence {
7675        fallow_output::RuntimeCoverageEvidence {
7676            static_status: static_status.to_owned(),
7677            test_coverage: test_coverage.to_owned(),
7678            v8_tracking: v8_tracking.to_owned(),
7679            untracked_reason: None,
7680            observation_days: 7,
7681            deployments_observed: 2,
7682        }
7683    }
7684
7685    #[test]
7686    #[expect(
7687        clippy::too_many_lines,
7688        reason = "test fixture; linear setup/assert, length is not a maintainability concern"
7689    )]
7690    fn runtime_coverage_top_applies_after_baseline_filtering() {
7691        let root = Path::new("/project");
7692        let baseline = HealthBaselineData {
7693            findings: vec![],
7694            finding_counts: std::collections::BTreeMap::new(),
7695            runtime_coverage_findings: vec![
7696                "fallow:prod:aaaaaaaa".to_owned(),
7697                "fallow:prod:bbbbbbbb".to_owned(),
7698            ],
7699            runtime_coverage_source_hashes: vec![],
7700            target_keys: vec![],
7701        };
7702        let mut report = fallow_output::RuntimeCoverageReport {
7703            schema_version: fallow_output::RuntimeCoverageSchemaVersion::V1,
7704            verdict: fallow_output::RuntimeCoverageReportVerdict::ColdCodeDetected,
7705            signals: Vec::new(),
7706            summary: fx_summary(3, 0, 2, 1),
7707            findings: vec![
7708                fallow_output::RuntimeCoverageFinding {
7709                    id: "fallow:prod:aaaaaaaa".to_owned(),
7710                    stable_id: None,
7711                    path: PathBuf::from("/project/src/a.ts"),
7712                    function: "alpha".to_owned(),
7713                    line: 10,
7714                    verdict: fallow_output::RuntimeCoverageVerdict::ReviewRequired,
7715                    invocations: Some(0),
7716                    confidence: fallow_output::RuntimeCoverageConfidence::Medium,
7717                    evidence: fx_evidence("used", "not_covered", "tracked"),
7718                    actions: vec![],
7719                    source_hash: None,
7720                    discriminators: None,
7721                },
7722                fallow_output::RuntimeCoverageFinding {
7723                    id: "fallow:prod:bbbbbbbb".to_owned(),
7724                    stable_id: None,
7725                    path: PathBuf::from("/project/src/b.ts"),
7726                    function: "beta".to_owned(),
7727                    line: 20,
7728                    verdict: fallow_output::RuntimeCoverageVerdict::CoverageUnavailable,
7729                    invocations: None,
7730                    confidence: fallow_output::RuntimeCoverageConfidence::None,
7731                    evidence: fx_evidence("used", "not_covered", "untracked"),
7732                    actions: vec![],
7733                    source_hash: None,
7734                    discriminators: None,
7735                },
7736                fallow_output::RuntimeCoverageFinding {
7737                    id: "fallow:prod:cccccccc".to_owned(),
7738                    stable_id: None,
7739                    path: PathBuf::from("/project/src/c.ts"),
7740                    function: "gamma".to_owned(),
7741                    line: 30,
7742                    verdict: fallow_output::RuntimeCoverageVerdict::ReviewRequired,
7743                    invocations: Some(0),
7744                    confidence: fallow_output::RuntimeCoverageConfidence::Medium,
7745                    evidence: fx_evidence("used", "not_covered", "tracked"),
7746                    actions: vec![],
7747                    source_hash: None,
7748                    discriminators: None,
7749                },
7750            ],
7751            hot_paths: vec![
7752                fallow_output::RuntimeCoverageHotPath {
7753                    id: "fallow:hot:11111111".to_owned(),
7754                    stable_id: None,
7755                    path: PathBuf::from("/project/src/hot-a.ts"),
7756                    function: "hotAlpha".to_owned(),
7757                    line: 1,
7758                    end_line: 5,
7759                    invocations: 500,
7760                    percentile: 99,
7761                    actions: vec![],
7762                },
7763                fallow_output::RuntimeCoverageHotPath {
7764                    id: "fallow:hot:22222222".to_owned(),
7765                    stable_id: None,
7766                    path: PathBuf::from("/project/src/hot-b.ts"),
7767                    function: "hotBeta".to_owned(),
7768                    line: 2,
7769                    end_line: 8,
7770                    invocations: 250,
7771                    percentile: 50,
7772                    actions: vec![],
7773                },
7774            ],
7775            blast_radius: vec![],
7776            importance: vec![],
7777            watermark: None,
7778            warnings: vec![],
7779            actionable: true,
7780            actionability_reason: None,
7781            actionability_verdict: None,
7782            provenance: fallow_output::RuntimeCoverageProvenance::default(),
7783        };
7784
7785        apply_runtime_coverage_filters(
7786            &mut report,
7787            &RuntimeCoverageFilterContext::new(root)
7788                .with_baseline(Some(&baseline))
7789                .with_top(Some(1)),
7790        );
7791
7792        assert_eq!(report.findings.len(), 1);
7793        assert_eq!(report.findings[0].function, "gamma");
7794        assert_eq!(
7795            report.verdict,
7796            fallow_output::RuntimeCoverageReportVerdict::ColdCodeDetected
7797        );
7798        assert_eq!(report.summary.functions_tracked, 3);
7799        assert_eq!(report.summary.functions_hit, 0);
7800        assert_eq!(report.summary.functions_unhit, 2);
7801        assert_eq!(report.summary.functions_untracked, 1);
7802        assert!((report.summary.coverage_percent - 0.0).abs() < 0.05);
7803        assert_eq!(report.hot_paths.len(), 1);
7804        assert_eq!(report.hot_paths[0].function, "hotAlpha");
7805    }
7806
7807    #[test]
7808    fn runtime_coverage_baseline_refreshes_to_clean_when_only_baselined_findings_remain() {
7809        let root = Path::new("/project");
7810        let baseline = HealthBaselineData {
7811            findings: vec![],
7812            finding_counts: std::collections::BTreeMap::new(),
7813            runtime_coverage_findings: vec!["fallow:prod:aaaaaaaa".to_owned()],
7814            runtime_coverage_source_hashes: vec![],
7815            target_keys: vec![],
7816        };
7817        let mut report = fallow_output::RuntimeCoverageReport {
7818            schema_version: fallow_output::RuntimeCoverageSchemaVersion::V1,
7819            verdict: fallow_output::RuntimeCoverageReportVerdict::ColdCodeDetected,
7820            signals: Vec::new(),
7821            summary: fx_summary(2, 1, 1, 0),
7822            findings: vec![fallow_output::RuntimeCoverageFinding {
7823                id: "fallow:prod:aaaaaaaa".to_owned(),
7824                stable_id: None,
7825                path: PathBuf::from("/project/src/a.ts"),
7826                function: "alpha".to_owned(),
7827                line: 10,
7828                verdict: fallow_output::RuntimeCoverageVerdict::ReviewRequired,
7829                invocations: Some(0),
7830                confidence: fallow_output::RuntimeCoverageConfidence::Medium,
7831                evidence: fx_evidence("used", "not_covered", "tracked"),
7832                actions: vec![],
7833                source_hash: None,
7834                discriminators: None,
7835            }],
7836            hot_paths: vec![],
7837            blast_radius: vec![],
7838            importance: vec![],
7839            watermark: None,
7840            warnings: vec![],
7841            actionable: true,
7842            actionability_reason: None,
7843            actionability_verdict: None,
7844            provenance: fallow_output::RuntimeCoverageProvenance::default(),
7845        };
7846
7847        apply_runtime_coverage_filters(
7848            &mut report,
7849            &RuntimeCoverageFilterContext::new(root).with_baseline(Some(&baseline)),
7850        );
7851
7852        assert!(report.findings.is_empty());
7853        assert_eq!(
7854            report.verdict,
7855            fallow_output::RuntimeCoverageReportVerdict::Clean
7856        );
7857        assert_eq!(report.summary.functions_tracked, 2);
7858        assert_eq!(report.summary.functions_hit, 1);
7859        assert_eq!(report.summary.functions_unhit, 1);
7860        assert_eq!(report.summary.functions_untracked, 0);
7861        assert!((report.summary.coverage_percent - 50.0).abs() < 0.05);
7862    }
7863
7864    #[test]
7865    fn runtime_coverage_changed_review_uses_hot_path_verdict() {
7866        let root = Path::new("/project");
7867        let mut changed_files = FxHashSet::default();
7868        changed_files.insert(PathBuf::from("/project/src/hot.ts"));
7869        let mut report = fallow_output::RuntimeCoverageReport {
7870            schema_version: fallow_output::RuntimeCoverageSchemaVersion::V1,
7871            verdict: fallow_output::RuntimeCoverageReportVerdict::Clean,
7872            signals: Vec::new(),
7873            summary: fx_summary(2, 2, 0, 0),
7874            findings: vec![],
7875            hot_paths: vec![fallow_output::RuntimeCoverageHotPath {
7876                id: "fallow:hot:33333333".to_owned(),
7877                stable_id: None,
7878                path: PathBuf::from("/project/src/hot.ts"),
7879                function: "renderHotPath".to_owned(),
7880                line: 7,
7881                end_line: 24,
7882                invocations: 9_500,
7883                percentile: 99,
7884                actions: vec![],
7885            }],
7886            blast_radius: vec![],
7887            importance: vec![],
7888            watermark: None,
7889            warnings: vec![],
7890            actionable: true,
7891            actionability_reason: None,
7892            actionability_verdict: None,
7893            provenance: fallow_output::RuntimeCoverageProvenance::default(),
7894        };
7895
7896        apply_runtime_coverage_filters(
7897            &mut report,
7898            &RuntimeCoverageFilterContext::new(root).with_changed_files(Some(&changed_files)),
7899        );
7900
7901        assert_eq!(
7902            report.verdict,
7903            fallow_output::RuntimeCoverageReportVerdict::HotPathTouched
7904        );
7905    }
7906
7907    #[test]
7908    fn runtime_coverage_changed_review_ignores_unmodified_hot_paths() {
7909        let root = Path::new("/project");
7910        let mut changed_files = FxHashSet::default();
7911        changed_files.insert(PathBuf::from("/project/src/other.ts"));
7912        let mut report = fallow_output::RuntimeCoverageReport {
7913            schema_version: fallow_output::RuntimeCoverageSchemaVersion::V1,
7914            verdict: fallow_output::RuntimeCoverageReportVerdict::Clean,
7915            signals: Vec::new(),
7916            summary: fx_summary(2, 2, 0, 0),
7917            findings: vec![],
7918            hot_paths: vec![fallow_output::RuntimeCoverageHotPath {
7919                id: "fallow:hot:44444444".to_owned(),
7920                stable_id: None,
7921                path: PathBuf::from("/project/src/hot.ts"),
7922                function: "renderHotPath".to_owned(),
7923                line: 7,
7924                end_line: 24,
7925                invocations: 9_500,
7926                percentile: 90,
7927                actions: vec![],
7928            }],
7929            blast_radius: vec![],
7930            importance: vec![],
7931            watermark: None,
7932            warnings: vec![],
7933            actionable: true,
7934            actionability_reason: None,
7935            actionability_verdict: None,
7936            provenance: fallow_output::RuntimeCoverageProvenance::default(),
7937        };
7938
7939        apply_runtime_coverage_filters(
7940            &mut report,
7941            &RuntimeCoverageFilterContext::new(root).with_changed_files(Some(&changed_files)),
7942        );
7943
7944        assert!(report.hot_paths.is_empty());
7945        assert_eq!(
7946            report.verdict,
7947            fallow_output::RuntimeCoverageReportVerdict::Clean
7948        );
7949    }
7950
7951    fn fx_runtime_coverage_report_with_hot_paths(
7952        hot_paths: Vec<fallow_output::RuntimeCoverageHotPath>,
7953    ) -> fallow_output::RuntimeCoverageReport {
7954        fallow_output::RuntimeCoverageReport {
7955            schema_version: fallow_output::RuntimeCoverageSchemaVersion::V1,
7956            verdict: fallow_output::RuntimeCoverageReportVerdict::Clean,
7957            signals: Vec::new(),
7958            summary: fx_summary(2, 2, 0, 0),
7959            findings: vec![],
7960            hot_paths,
7961            blast_radius: vec![],
7962            importance: vec![],
7963            watermark: None,
7964            warnings: vec![],
7965            actionable: true,
7966            actionability_reason: None,
7967            actionability_verdict: None,
7968            provenance: fallow_output::RuntimeCoverageProvenance::default(),
7969        }
7970    }
7971
7972    fn fx_hot_path(
7973        id: &str,
7974        path: &str,
7975        line: u32,
7976        end_line: u32,
7977    ) -> fallow_output::RuntimeCoverageHotPath {
7978        fallow_output::RuntimeCoverageHotPath {
7979            id: id.to_owned(),
7980            stable_id: None,
7981            path: PathBuf::from(path),
7982            function: "renderHotPath".to_owned(),
7983            line,
7984            end_line,
7985            invocations: 9_500,
7986            percentile: 99,
7987            actions: vec![],
7988        }
7989    }
7990
7991    #[test]
7992    fn runtime_coverage_diff_index_keeps_hot_paths_with_added_line_in_range() {
7993        let root = Path::new("/project");
7994        let diff = "diff --git a/src/hot.ts b/src/hot.ts\n\
7995                    --- a/src/hot.ts\n\
7996                    +++ b/src/hot.ts\n\
7997                    @@ -10,1 +10,2 @@\n\
7998                    +  // touch the body\n\
7999                    line 11\n";
8000        let diff_index = fallow_output::DiffIndex::from_unified_diff(diff);
8001        let mut report = fx_runtime_coverage_report_with_hot_paths(vec![fx_hot_path(
8002            "fallow:hot:01010101",
8003            "src/hot.ts",
8004            7,
8005            24,
8006        )]);
8007
8008        apply_runtime_coverage_filters(
8009            &mut report,
8010            &RuntimeCoverageFilterContext::new(root).with_diff_index(Some(&diff_index)),
8011        );
8012
8013        assert_eq!(report.hot_paths.len(), 1);
8014        assert_eq!(
8015            report.verdict,
8016            fallow_output::RuntimeCoverageReportVerdict::HotPathTouched
8017        );
8018    }
8019
8020    #[test]
8021    fn runtime_coverage_diff_index_drops_hot_paths_when_added_line_outside_range() {
8022        let root = Path::new("/project");
8023        let diff = "diff --git a/src/hot.ts b/src/hot.ts\n\
8024                    --- a/src/hot.ts\n\
8025                    +++ b/src/hot.ts\n\
8026                    @@ -50,1 +50,2 @@\n\
8027                    +  // unrelated change far below the hot function\n\
8028                    line 51\n";
8029        let diff_index = fallow_output::DiffIndex::from_unified_diff(diff);
8030        let mut report = fx_runtime_coverage_report_with_hot_paths(vec![fx_hot_path(
8031            "fallow:hot:02020202",
8032            "src/hot.ts",
8033            7,
8034            24,
8035        )]);
8036
8037        apply_runtime_coverage_filters(
8038            &mut report,
8039            &RuntimeCoverageFilterContext::new(root).with_diff_index(Some(&diff_index)),
8040        );
8041
8042        assert!(report.hot_paths.is_empty());
8043        assert_eq!(
8044            report.verdict,
8045            fallow_output::RuntimeCoverageReportVerdict::Clean
8046        );
8047    }
8048
8049    #[test]
8050    fn runtime_coverage_diff_index_falls_back_to_single_line_when_end_line_zero() {
8051        let root = Path::new("/project");
8052        let diff = "diff --git a/src/hot.ts b/src/hot.ts\n\
8053                    --- a/src/hot.ts\n\
8054                    +++ b/src/hot.ts\n\
8055                    @@ -7,1 +7,2 @@\n\
8056                    +  // exactly the function's start line\n\
8057                    line 8\n";
8058        let diff_index = fallow_output::DiffIndex::from_unified_diff(diff);
8059        let mut report = fx_runtime_coverage_report_with_hot_paths(vec![fx_hot_path(
8060            "fallow:hot:03030303",
8061            "src/hot.ts",
8062            7,
8063            0,
8064        )]);
8065
8066        apply_runtime_coverage_filters(
8067            &mut report,
8068            &RuntimeCoverageFilterContext::new(root).with_diff_index(Some(&diff_index)),
8069        );
8070
8071        assert_eq!(report.hot_paths.len(), 1);
8072        assert_eq!(
8073            report.verdict,
8074            fallow_output::RuntimeCoverageReportVerdict::HotPathTouched
8075        );
8076    }
8077
8078    #[test]
8079    fn runtime_coverage_diff_index_resolves_absolute_hot_path_against_root() {
8080        let root = Path::new("/project");
8081        let diff = "diff --git a/src/hot.ts b/src/hot.ts\n\
8082                    --- a/src/hot.ts\n\
8083                    +++ b/src/hot.ts\n\
8084                    @@ -10,1 +10,2 @@\n\
8085                    +  // touched\n\
8086                    line 11\n";
8087        let diff_index = fallow_output::DiffIndex::from_unified_diff(diff);
8088        let mut report = fx_runtime_coverage_report_with_hot_paths(vec![fx_hot_path(
8089            "fallow:hot:04040404",
8090            "/project/src/hot.ts",
8091            7,
8092            24,
8093        )]);
8094
8095        apply_runtime_coverage_filters(
8096            &mut report,
8097            &RuntimeCoverageFilterContext::new(root).with_diff_index(Some(&diff_index)),
8098        );
8099
8100        assert_eq!(report.hot_paths.len(), 1);
8101    }
8102
8103    #[test]
8104    fn runtime_coverage_diff_index_authoritative_for_files_in_diff() {
8105        let root = Path::new("/project");
8106        let diff = "diff --git a/src/hot.ts b/src/hot.ts\n\
8107                    --- a/src/hot.ts\n\
8108                    +++ b/src/hot.ts\n\
8109                    @@ -50,1 +50,2 @@\n\
8110                    +  // outside the hot function\n\
8111                    line 51\n";
8112        let diff_index = fallow_output::DiffIndex::from_unified_diff(diff);
8113        let mut changed_files = FxHashSet::default();
8114        changed_files.insert(PathBuf::from("/project/src/hot.ts"));
8115        let mut report = fx_runtime_coverage_report_with_hot_paths(vec![fx_hot_path(
8116            "fallow:hot:05050505",
8117            "src/hot.ts",
8118            7,
8119            24,
8120        )]);
8121
8122        apply_runtime_coverage_filters(
8123            &mut report,
8124            &RuntimeCoverageFilterContext::new(root)
8125                .with_changed_files(Some(&changed_files))
8126                .with_diff_index(Some(&diff_index)),
8127        );
8128
8129        assert!(report.hot_paths.is_empty());
8130        assert_eq!(
8131            report.verdict,
8132            fallow_output::RuntimeCoverageReportVerdict::Clean
8133        );
8134    }
8135
8136    #[test]
8137    fn runtime_coverage_per_file_fallback_to_changed_files_when_diff_omits_file() {
8138        let root = Path::new("/project");
8139        let diff = "diff --git a/src/other.ts b/src/other.ts\n\
8140                    --- a/src/other.ts\n\
8141                    +++ b/src/other.ts\n\
8142                    @@ -1,1 +1,2 @@\n\
8143                    +  // unrelated\n\
8144                    line 2\n";
8145        let diff_index = fallow_output::DiffIndex::from_unified_diff(diff);
8146        let mut changed_files = FxHashSet::default();
8147        changed_files.insert(PathBuf::from("/project/src/hot.ts"));
8148        let mut report = fx_runtime_coverage_report_with_hot_paths(vec![fx_hot_path(
8149            "fallow:hot:0a0a0a0a",
8150            "src/hot.ts",
8151            7,
8152            24,
8153        )]);
8154
8155        apply_runtime_coverage_filters(
8156            &mut report,
8157            &RuntimeCoverageFilterContext::new(root)
8158                .with_changed_files(Some(&changed_files))
8159                .with_diff_index(Some(&diff_index)),
8160        );
8161
8162        assert_eq!(report.hot_paths.len(), 1);
8163        assert_eq!(
8164            report.verdict,
8165            fallow_output::RuntimeCoverageReportVerdict::HotPathTouched
8166        );
8167    }
8168
8169    #[test]
8170    fn runtime_coverage_pr_context_promotes_hot_path_touched_above_cold_code() {
8171        let root = Path::new("/project");
8172        let mut changed_files = FxHashSet::default();
8173        changed_files.insert(PathBuf::from("/project/src/hot.ts"));
8174        let mut report = fallow_output::RuntimeCoverageReport {
8175            schema_version: fallow_output::RuntimeCoverageSchemaVersion::V1,
8176            verdict: fallow_output::RuntimeCoverageReportVerdict::ColdCodeDetected,
8177            signals: Vec::new(),
8178            summary: fx_summary(2, 1, 1, 0),
8179            findings: vec![fallow_output::RuntimeCoverageFinding {
8180                id: "fallow:prod:cold0001".to_owned(),
8181                stable_id: None,
8182                path: PathBuf::from("/project/src/cold.ts"),
8183                function: "coldFn".to_owned(),
8184                line: 4,
8185                verdict: fallow_output::RuntimeCoverageVerdict::SafeToDelete,
8186                invocations: Some(0),
8187                confidence: fallow_output::RuntimeCoverageConfidence::High,
8188                evidence: fx_evidence("unused", "not_covered", "tracked"),
8189                actions: vec![],
8190                source_hash: None,
8191                discriminators: None,
8192            }],
8193            hot_paths: vec![fx_hot_path("fallow:hot:0b0b0b0b", "src/hot.ts", 7, 24)],
8194            blast_radius: vec![],
8195            importance: vec![],
8196            watermark: None,
8197            warnings: vec![],
8198            actionable: true,
8199            actionability_reason: None,
8200            actionability_verdict: None,
8201            provenance: fallow_output::RuntimeCoverageProvenance::default(),
8202        };
8203
8204        apply_runtime_coverage_filters(
8205            &mut report,
8206            &RuntimeCoverageFilterContext::new(root).with_changed_files(Some(&changed_files)),
8207        );
8208
8209        assert_eq!(
8210            report.verdict,
8211            fallow_output::RuntimeCoverageReportVerdict::HotPathTouched
8212        );
8213        assert_eq!(
8214            report.signals,
8215            vec![
8216                fallow_output::RuntimeCoverageSignal::ColdCodeDetected,
8217                fallow_output::RuntimeCoverageSignal::HotPathTouched,
8218            ]
8219        );
8220    }
8221
8222    #[test]
8223    fn runtime_coverage_standalone_keeps_cold_code_primary_above_unchanged_hot_paths() {
8224        let root = Path::new("/project");
8225        let mut report = fallow_output::RuntimeCoverageReport {
8226            schema_version: fallow_output::RuntimeCoverageSchemaVersion::V1,
8227            verdict: fallow_output::RuntimeCoverageReportVerdict::Clean,
8228            signals: Vec::new(),
8229            summary: fx_summary(2, 1, 1, 0),
8230            findings: vec![fallow_output::RuntimeCoverageFinding {
8231                id: "fallow:prod:cold0002".to_owned(),
8232                stable_id: None,
8233                path: PathBuf::from("/project/src/cold.ts"),
8234                function: "coldFn".to_owned(),
8235                line: 4,
8236                verdict: fallow_output::RuntimeCoverageVerdict::SafeToDelete,
8237                invocations: Some(0),
8238                confidence: fallow_output::RuntimeCoverageConfidence::High,
8239                evidence: fx_evidence("unused", "not_covered", "tracked"),
8240                actions: vec![],
8241                source_hash: None,
8242                discriminators: None,
8243            }],
8244            hot_paths: vec![fx_hot_path("fallow:hot:0c0c0c0c", "src/hot.ts", 7, 24)],
8245            blast_radius: vec![],
8246            importance: vec![],
8247            watermark: None,
8248            warnings: vec![],
8249            actionable: true,
8250            actionability_reason: None,
8251            actionability_verdict: None,
8252            provenance: fallow_output::RuntimeCoverageProvenance::default(),
8253        };
8254
8255        apply_runtime_coverage_filters(&mut report, &RuntimeCoverageFilterContext::new(root));
8256
8257        assert_eq!(
8258            report.verdict,
8259            fallow_output::RuntimeCoverageReportVerdict::ColdCodeDetected
8260        );
8261        assert_eq!(
8262            report.signals,
8263            vec![fallow_output::RuntimeCoverageSignal::ColdCodeDetected]
8264        );
8265        assert_eq!(report.hot_paths.len(), 1);
8266    }
8267
8268    #[test]
8269    fn runtime_coverage_license_grace_outranks_pr_context_signals() {
8270        let root = Path::new("/project");
8271        let mut changed_files = FxHashSet::default();
8272        changed_files.insert(PathBuf::from("/project/src/hot.ts"));
8273        let mut report = fallow_output::RuntimeCoverageReport {
8274            schema_version: fallow_output::RuntimeCoverageSchemaVersion::V1,
8275            verdict: fallow_output::RuntimeCoverageReportVerdict::LicenseExpiredGrace,
8276            signals: Vec::new(),
8277            summary: fx_summary(2, 1, 1, 0),
8278            findings: vec![],
8279            hot_paths: vec![fx_hot_path("fallow:hot:0d0d0d0d", "src/hot.ts", 7, 24)],
8280            blast_radius: vec![],
8281            importance: vec![],
8282            watermark: Some(fallow_output::RuntimeCoverageWatermark::LicenseExpiredGrace),
8283            warnings: vec![],
8284            actionable: true,
8285            actionability_reason: None,
8286            actionability_verdict: None,
8287            provenance: fallow_output::RuntimeCoverageProvenance::default(),
8288        };
8289
8290        apply_runtime_coverage_filters(
8291            &mut report,
8292            &RuntimeCoverageFilterContext::new(root).with_changed_files(Some(&changed_files)),
8293        );
8294
8295        assert_eq!(
8296            report.verdict,
8297            fallow_output::RuntimeCoverageReportVerdict::LicenseExpiredGrace
8298        );
8299        assert!(
8300            report
8301                .signals
8302                .contains(&fallow_output::RuntimeCoverageSignal::LicenseExpiredGrace)
8303        );
8304        assert!(
8305            report
8306                .signals
8307                .contains(&fallow_output::RuntimeCoverageSignal::HotPathTouched)
8308        );
8309    }
8310
8311    #[test]
8312    fn retain_hot_paths_drops_when_diff_touches_file_but_no_added_lines() {
8313        let root = Path::new("/project");
8314        let diff = fallow_output::DiffIndex::from_unified_diff(
8315            "diff --git a/src/hot.ts b/src/hot.ts\n\
8316             --- a/src/hot.ts\n\
8317             +++ b/src/hot.ts\n\
8318             @@ -10,3 +10,1 @@\n\
8319             -one\n\
8320             -two\n\
8321             -three\n\
8322             ctx\n",
8323        );
8324        let mut changed_files = FxHashSet::default();
8325        changed_files.insert(PathBuf::from("/project/src/hot.ts"));
8326        let mut report = fx_runtime_coverage_report_with_hot_paths(vec![fx_hot_path(
8327            "fallow:hot:deletiononly",
8328            "src/hot.ts",
8329            10,
8330            12,
8331        )]);
8332
8333        apply_runtime_coverage_filters(
8334            &mut report,
8335            &RuntimeCoverageFilterContext::new(root)
8336                .with_diff_index(Some(&diff))
8337                .with_changed_files(Some(&changed_files)),
8338        );
8339
8340        assert!(
8341            report.hot_paths.is_empty(),
8342            "diff touched the file with no added lines: must drop, not fall through to changed_files"
8343        );
8344    }
8345
8346    #[test]
8347    fn runtime_coverage_changed_files_matches_relative_hot_path_against_absolute_set() {
8348        let root = Path::new("/project");
8349        let mut changed_files = FxHashSet::default();
8350        changed_files.insert(PathBuf::from("/project/src/hot.ts"));
8351        let mut report = fx_runtime_coverage_report_with_hot_paths(vec![fx_hot_path(
8352            "fallow:hot:06060606",
8353            "src/hot.ts",
8354            7,
8355            24,
8356        )]);
8357
8358        apply_runtime_coverage_filters(
8359            &mut report,
8360            &RuntimeCoverageFilterContext::new(root).with_changed_files(Some(&changed_files)),
8361        );
8362
8363        assert_eq!(report.hot_paths.len(), 1);
8364    }
8365
8366    fn make_class_finding(
8367        path: &str,
8368        name: &str,
8369        line: u32,
8370        cyclomatic: u16,
8371        cognitive: u16,
8372    ) -> ComplexityViolation {
8373        ComplexityViolation {
8374            path: PathBuf::from(path),
8375            name: name.to_string(),
8376            line,
8377            col: 0,
8378            cyclomatic,
8379            cognitive,
8380            line_count: 20,
8381            param_count: 0,
8382            react_hook_count: 0,
8383            react_jsx_max_depth: 0,
8384            react_prop_count: 0,
8385            react_hook_profile: None,
8386            exceeded: ExceededThreshold::Both,
8387            severity: FindingSeverity::Moderate,
8388            crap: None,
8389            coverage_pct: None,
8390            coverage_tier: None,
8391            coverage_source: None,
8392            inherited_from: None,
8393            component_rollup: None,
8394            contributions: Vec::new(),
8395            effective_thresholds: None,
8396            threshold_source: None,
8397        }
8398    }
8399
8400    fn make_template_finding(
8401        path: &str,
8402        line: u32,
8403        cyclomatic: u16,
8404        cognitive: u16,
8405    ) -> ComplexityViolation {
8406        ComplexityViolation {
8407            path: PathBuf::from(path),
8408            name: "<template>".to_string(),
8409            line,
8410            col: 0,
8411            cyclomatic,
8412            cognitive,
8413            line_count: 30,
8414            param_count: 0,
8415            react_hook_count: 0,
8416            react_jsx_max_depth: 0,
8417            react_prop_count: 0,
8418            react_hook_profile: None,
8419            exceeded: ExceededThreshold::Both,
8420            severity: FindingSeverity::Moderate,
8421            crap: None,
8422            coverage_pct: None,
8423            coverage_tier: None,
8424            coverage_source: None,
8425            inherited_from: None,
8426            component_rollup: None,
8427            contributions: Vec::new(),
8428            effective_thresholds: None,
8429            threshold_source: None,
8430        }
8431    }
8432
8433    #[test]
8434    fn rollup_external_template_via_provenance_lookup() {
8435        let component_ts = PathBuf::from("/proj/src/host-game.component.ts");
8436        let template_html = PathBuf::from("/proj/src/host-game.component.html");
8437        let mut findings = vec![
8438            make_class_finding(component_ts.to_str().unwrap(), "handleClick", 42, 3, 4),
8439            make_template_finding(template_html.to_str().unwrap(), 1, 6, 10),
8440        ];
8441        let mut lookup = rustc_hash::FxHashMap::default();
8442        lookup.insert(template_html.clone(), component_ts.clone());
8443        append_component_rollup_findings(&mut findings, Some(&lookup), 8, 8);
8444
8445        assert_eq!(findings.len(), 3, "rollup is strictly additive");
8446        let rollup = findings
8447            .iter()
8448            .find(|f| f.name == "<component>")
8449            .expect("rollup must be present");
8450        assert_eq!(rollup.path, component_ts);
8451        assert_eq!(rollup.cyclomatic, 9, "9 = worst class 3 + template 6");
8452        assert_eq!(rollup.cognitive, 14, "14 = worst class 4 + template 10");
8453        assert_eq!(rollup.line, 42, "anchored at worst class function line");
8454        let breakdown = rollup.component_rollup.as_ref().expect("breakdown present");
8455        assert_eq!(
8456            breakdown.component, "host-game.component",
8457            "component identifier is the .ts owner's file stem"
8458        );
8459        assert_eq!(breakdown.class_worst_function, "handleClick");
8460        assert_eq!(breakdown.class_cyclomatic, 3);
8461        assert_eq!(breakdown.template_cyclomatic, 6);
8462        assert_eq!(breakdown.template_path, template_html);
8463    }
8464
8465    #[test]
8466    fn rollup_inline_template_owner_is_same_ts_file() {
8467        let component_ts = PathBuf::from("/proj/src/inline.component.ts");
8468        let mut findings = vec![
8469            make_class_finding(component_ts.to_str().unwrap(), "ngOnInit", 25, 5, 8),
8470            make_template_finding(component_ts.to_str().unwrap(), 10, 4, 6),
8471        ];
8472        append_component_rollup_findings(&mut findings, None, 8, 8);
8473
8474        let rollup = findings
8475            .iter()
8476            .find(|f| f.name == "<component>")
8477            .expect("rollup must be present for inline-template case without provenance lookup");
8478        assert_eq!(rollup.cyclomatic, 9);
8479        assert_eq!(rollup.cognitive, 14);
8480        let breakdown = rollup.component_rollup.as_ref().unwrap();
8481        assert_eq!(breakdown.template_path, component_ts);
8482        assert_eq!(breakdown.component, "inline.component");
8483    }
8484
8485    #[test]
8486    fn rollup_picks_worst_class_function_by_cyclomatic() {
8487        let component_ts = PathBuf::from("/proj/src/multi.component.ts");
8488        let template = PathBuf::from("/proj/src/multi.component.html");
8489        let mut findings = vec![
8490            make_class_finding(component_ts.to_str().unwrap(), "first", 10, 3, 4),
8491            make_class_finding(component_ts.to_str().unwrap(), "worst", 20, 8, 9),
8492            make_class_finding(component_ts.to_str().unwrap(), "middle", 30, 5, 6),
8493            make_template_finding(template.to_str().unwrap(), 1, 4, 6),
8494        ];
8495        let mut lookup = rustc_hash::FxHashMap::default();
8496        lookup.insert(template, component_ts);
8497        append_component_rollup_findings(&mut findings, Some(&lookup), 8, 8);
8498
8499        let rollup = findings.iter().find(|f| f.name == "<component>").unwrap();
8500        assert_eq!(rollup.cyclomatic, 12, "8 (worst.cyc) + 4 (template.cyc)");
8501        let breakdown = rollup.component_rollup.as_ref().unwrap();
8502        assert_eq!(breakdown.class_worst_function, "worst");
8503        assert_eq!(breakdown.class_cyclomatic, 8);
8504    }
8505
8506    #[test]
8507    fn rollup_skipped_when_no_template_finding() {
8508        let component_ts = "/proj/src/only-class.component.ts";
8509        let mut findings = vec![make_class_finding(component_ts, "Foo.method", 10, 5, 7)];
8510        let before = findings.len();
8511        append_component_rollup_findings(&mut findings, None, 30, 25);
8512        assert_eq!(findings.len(), before, "no template means no rollup");
8513    }
8514
8515    #[test]
8516    fn rollup_skipped_when_no_class_findings() {
8517        let template_html = PathBuf::from("/proj/src/orphan.component.html");
8518        let component_ts = PathBuf::from("/proj/src/orphan.component.ts");
8519        let mut findings = vec![make_template_finding(
8520            template_html.to_str().unwrap(),
8521            1,
8522            6,
8523            10,
8524        )];
8525        let mut lookup = rustc_hash::FxHashMap::default();
8526        lookup.insert(template_html, component_ts);
8527        let before = findings.len();
8528        append_component_rollup_findings(&mut findings, Some(&lookup), 8, 8);
8529        assert_eq!(
8530            findings.len(),
8531            before,
8532            "no class methods above threshold means no rollup"
8533        );
8534    }
8535
8536    #[test]
8537    fn rollup_skipped_when_multiple_templates_on_one_owner() {
8538        let component_ts = PathBuf::from("/proj/src/twin.component.ts");
8539        let mut findings = vec![
8540            make_class_finding(component_ts.to_str().unwrap(), "TwinA.fn", 10, 5, 7),
8541            make_template_finding(component_ts.to_str().unwrap(), 5, 3, 4),
8542            make_template_finding(component_ts.to_str().unwrap(), 50, 4, 5),
8543        ];
8544        let before = findings.len();
8545        append_component_rollup_findings(&mut findings, None, 30, 25);
8546        assert_eq!(
8547            findings.len(),
8548            before,
8549            "two templates on one owner is defensively skipped"
8550        );
8551    }
8552
8553    #[test]
8554    fn rollup_external_template_skipped_when_lookup_missing() {
8555        let template_html = PathBuf::from("/proj/src/no-owner.component.html");
8556        let component_ts = "/proj/src/no-owner.component.ts";
8557        let mut findings = vec![
8558            make_class_finding(component_ts, "NoOwner.fn", 10, 5, 7),
8559            make_template_finding(template_html.to_str().unwrap(), 1, 6, 10),
8560        ];
8561        let before = findings.len();
8562        append_component_rollup_findings(&mut findings, None, 30, 25);
8563        assert_eq!(findings.len(), before);
8564    }
8565}