Skip to main content

fallow_api/
lib.rs

1//! Programmatic API contract types for fallow.
2//!
3//! Runtime execution for dead-code, duplication and health lives here. The
4//! default health runner, `EngineHealthRunner`, runs the engine pipeline.
5//! Tests and hosts can inject a different runner. This crate owns the
6//! CLI-independent option, error, and output contracts so NAPI, future Rust
7//! embedders, and the engine facade can share them without depending on the
8//! CLI crate.
9#![warn(missing_docs)]
10#![cfg_attr(
11    test,
12    allow(
13        clippy::expect_used,
14        reason = "tests use expect to keep fixture setup concise"
15    )
16)]
17
18use std::path::{Path, PathBuf};
19use std::sync::Arc;
20use std::sync::atomic::AtomicBool;
21
22use fallow_config::EmailMode;
23use fallow_output::EffortEstimate;
24use serde::Serialize;
25
26mod analysis_context;
27mod audit_complexity;
28/// Stable per-finding keys and audit ledgers that compare head results against
29/// a base snapshot, plus helpers that annotate output JSON with
30/// introduced-vs-pre-existing attribution.
31pub mod audit_keys;
32pub mod audit_output;
33pub mod audit_run;
34pub mod combined_output;
35/// One-line-per-finding compact text builders for dead-code, grouped, health,
36/// and duplication output.
37pub mod compact_output;
38pub mod coverage;
39pub mod dead_code_codeclimate;
40pub mod dead_code_sarif;
41pub mod decision_surface;
42pub mod dependency_deltas;
43pub mod diff_risk;
44pub mod doctor;
45pub mod dupes_output;
46pub mod editor;
47pub mod explain;
48pub mod grouped_output;
49pub mod health_codeclimate;
50mod health_groups_markdown;
51pub mod json_output;
52pub mod list_output;
53mod list_runtime;
54/// Markdown report builders for dead-code, grouped, duplication, health, and
55/// walkthrough output.
56pub mod markdown_output;
57pub mod mutation;
58mod next_steps;
59pub mod output_contracts;
60pub mod ownership;
61pub mod review_deltas;
62pub mod routing;
63pub mod runtime;
64mod runtime_json;
65mod runtime_output;
66pub mod sarif_output;
67/// JSON Schema documents and zero-config rule defaults re-exported from
68/// `fallow-config` for embedders such as the MCP resource surface.
69pub mod schemas;
70pub mod security_output;
71/// Local-only advisory similar-code discovery and provider status.
72pub mod similar_code;
73mod type_aware;
74pub use analysis_context::{ProgrammaticAnalysisContext, resolve_programmatic_analysis_context};
75pub use audit_output::{
76    AuditAttribution, AuditCodeClimateOutputInput, AuditJsonHeaderInput, AuditJsonOutputInput,
77    AuditSarifOutputInput, AuditSummary, AuditVerdict, attach_audit_diff_risk_attribution,
78    attach_audit_duplication_demotion_attribution, attach_audit_styling_attribution,
79    attach_audit_wire_attribution, build_audit_codeclimate, build_audit_codeclimate_issues,
80    build_audit_header_json, build_audit_header_map, build_audit_sarif,
81    build_diff_risk_codeclimate, build_review_brief_header, serialize_audit_json,
82};
83pub use combined_output::{
84    CombinedCheckJsonSection, CombinedJsonOutputInput, serialize_combined_dupes_json,
85    serialize_combined_health_json, serialize_combined_json,
86};
87pub use compact_output::{
88    build_compact_lines, build_diff_risk_compact_lines, build_duplication_compact_lines,
89    build_grouped_compact_lines, build_health_compact_lines,
90};
91pub use coverage::{
92    CoverageInputError, CoverageInputSource, CoverageInputs, resolve_coverage_inputs,
93};
94pub use dead_code_codeclimate::build_codeclimate;
95pub use dead_code_sarif::build_sarif;
96pub use doctor::{DoctorOptions, run_doctor, run_doctor_with_cache_dir};
97pub use dupes_output::{
98    AttributedCloneGroup, AttributedCloneGroupFinding, AttributedInstance, CloneDemotionReason,
99    CloneFamilyFinding, CloneGroupFinding, CombinedDupesSection, DupesReportPayload,
100    DuplicationGroup, DuplicationGrouping, build_duplication_codeclimate,
101};
102pub use editor::{
103    ChangedFilesError, EditorAnalysisOutput, EditorAnalysisResults, EditorAnalysisSession,
104    EditorCloneFamily, EditorCloneFingerprintSet, EditorCloneGroup, EditorCloneInstance,
105    EditorDeadCodeAnalysisOutput, EditorDuplicationReport, EditorDuplicationStats,
106    EditorInlineComplexityExceeded, EditorInlineComplexityFinding, EditorMirroredDirectory,
107    EditorProjectAnalysisOutput, EditorRefactoringKind, EditorRefactoringSuggestion,
108    EditorSessionParseCounts, collect_inline_complexity, editor_duplicates, editor_extract,
109    editor_results, editor_security, editor_suppress, filter_inline_complexity_by_change_scope,
110    filter_inline_complexity_by_changed_files, resolve_git_toplevel,
111    try_get_changed_files_with_toplevel,
112};
113pub use explain::{
114    AUDIT_RULES, CHECK_RULES, DUPES_RULES, FLAGS_RULES, HEALTH_RULES, RuleDef, RuleGuide,
115    SECURITY_RULES, all_rules, bare_rule_id, coverage_analyze_meta, coverage_setup_meta,
116    explain_issue_type, rule_by_id, rule_by_token, rule_command, rule_docs_url, rule_guide,
117    rule_severity_key, security_meta, serialize_explain_programmatic_json, unknown_explain_error,
118};
119pub use fallow_config::levenshtein::closest_match;
120pub use fallow_config::{AuditGate, AuditWorsened, HealthConfig, TypeAwareRequire};
121/// Engine-owned change scope of one run: a global changed-file set or the
122/// per-workspace Git baselines of `workspaces.changedSince`.
123pub use fallow_engine::change_scope::{
124    ChangeScope, ChangeScopeOwner, ChangeScopeRequest, first_package_baselines,
125    package_baseline_statuses,
126};
127/// Engine-owned per-workspace Git baseline state used by analysis surfaces.
128pub use fallow_engine::package_baselines::{PackageBaselineError, PackageChangeScope};
129/// Parsed modules that a long-lived process keeps across analysis calls.
130///
131/// A process that runs many calls on the same project, such as the MCP
132/// server, installs a store once. Each later analysis session then takes the
133/// modules of an unchanged file list from memory and does no parse work.
134pub use fallow_engine::warm_parse;
135/// Shared JSON and editor notification row for an applied package Git ref.
136pub use fallow_output::PackageBaselineStatus;
137pub use fallow_output::serialize_similar_code_json_output;
138pub use fallow_types::trace::{
139    CloneTrace, DependencyTrace, ExportReference, ExportTrace, FileTrace, ReExportChain,
140    TracedCloneGroup, TracedExport, TracedReExport,
141};
142pub use fallow_types::trace_usage::{
143    DependencyUsage, DependencyUsageQuery, SitePageRequest, UsageQueryError,
144};
145pub use grouped_output::{
146    ResultGroup, UNOWNED_GROUP_LABEL, build_duplication_grouping_with, group_analysis_results_with,
147    health_signal_header_part, largest_clone_group_owner_with,
148};
149pub use health_codeclimate::build_health_codeclimate;
150pub use health_groups_markdown::build_health_groups_markdown;
151pub use json_output::{
152    CheckJsonExtraOutputs, CheckJsonOutputInput, CheckJsonPayloadInput, DuplicationJsonOutputInput,
153    GroupedCheckJsonOutputInput, GroupedDuplicationJsonOutputInput, serialize_check_json,
154    serialize_check_json_payload, serialize_duplication_json, serialize_grouped_check_json,
155    serialize_grouped_duplication_json,
156};
157pub use list_output::{
158    ListJsonEnvelope, ListJsonOutputInput, build_list_json_output, serialize_list_json_output,
159};
160pub use list_runtime::{
161    BoundaryData, ListBoundariesOptions, ListBoundariesProgrammaticOutput, LogicalGroupInfo,
162    ProjectInfoOptions, ProjectInfoProgrammaticOutput, RuleInfo, ZoneInfo, boundary_data_to_output,
163    compute_boundary_data, run_list_boundaries, run_project_info,
164    serialize_list_boundaries_programmatic_json, serialize_project_info_programmatic_json,
165};
166pub use markdown_output::{
167    ComplexityListing, build_diff_risk_markdown, build_duplication_markdown,
168    build_grouped_markdown, build_health_markdown, build_markdown, build_walkthrough_markdown,
169};
170pub use mutation::{
171    MutationProgress, MutationProgressSink, MutationRunOptions, MutationRunScope,
172    apply_mutation_verdicts, validate_mutation_verdicts,
173};
174pub use output_contracts::{
175    AuditOutput, BoundariesListLogicalGroup, BoundariesListRule, BoundariesListZone,
176    BoundariesListing, CombinedOutput, FallowOutput, ImpactOutput, ListBoundariesOutput,
177    ListEntryPointOutput, ListOutput, ListPluginOutput, ReviewBriefWireOutput, SecurityGate,
178    SecurityOutput, SecurityOutputConfig, SecuritySummaryOutput, SimilarCodeCandidateSnapshot,
179    SimilarCodeOutput, TraceOutput, WorkspacesOutput,
180};
181pub use runtime::TYPE_AWARE_CONFIG_UNSUPPORTED;
182pub use runtime::{
183    AUDIT_CONFIG_BASELINE_UNSUPPORTED, ArchitectureOutput, ArchitectureProgrammaticOutput,
184    AuditProgrammaticKeySnapshot, AuditProgrammaticOutput, BoundaryViolationsOutput,
185    BoundaryViolationsProgrammaticOutput, CircularDependenciesOutput,
186    CircularDependenciesProgrammaticOutput, CombinedProgrammaticOutput, DeadCodeOutput,
187    DeadCodeProgrammaticOutput, DecisionSurfaceProgrammaticOutput, DuplicationOutput,
188    DuplicationProgrammaticOutput, EngineHealthRunner, FeatureFlagsOutput,
189    FeatureFlagsProgrammaticOutput, HealthJsonReportInput, HealthProgrammaticOutput,
190    ProgrammaticHealthAnalysis, ProgrammaticHealthNextStepFacts, ProgrammaticHealthRun,
191    ProgrammaticHealthRunner, TraceClassMemberOutput, TraceCloneBenchmarkResult, TraceCloneOutput,
192    TraceCloneProgrammaticOutput, TraceDependencyOutput, TraceDependencyProgrammaticOutput,
193    TraceErrorOutput, TraceErrorProgrammaticOutput, TraceExportOutput,
194    TraceExportProgrammaticOutput, TraceExportTargetOutput, TraceFileOutput,
195    TraceFileProgrammaticOutput, TraceImportPathOutput, TraceImportPathProgrammaticOutput,
196    benchmark_trace_clone_compact_json, benchmark_trace_graph_family_compact_json,
197    inspect_similar_code, load_health_config, parse_similar_code_candidate_snapshot, plan_mutation,
198    review_similar_code, run_architecture, run_audit, run_boundary_violations,
199    run_circular_dependencies, run_combined, run_complexity_with_runner, run_dead_code,
200    run_dead_code_with_baseline, run_decision_surface, run_duplication, run_feature_flags,
201    run_health, run_health_with_runner, run_mutation, run_mutation_with_progress, run_similar_code,
202    run_trace_clone, run_trace_dependency, run_trace_error, run_trace_export, run_trace_file,
203    run_trace_import_path, select_similar_code_candidate_snapshot, serialize_health_report_json,
204};
205pub use runtime_json::{
206    serialize_architecture_programmatic_json, serialize_audit_programmatic_json,
207    serialize_boundary_violations_programmatic_json,
208    serialize_circular_dependencies_programmatic_json, serialize_combined_programmatic_json,
209    serialize_dead_code_programmatic_json, serialize_decision_surface_programmatic_json,
210    serialize_duplication_programmatic_json, serialize_feature_flags_programmatic_json,
211    serialize_health_programmatic_json, serialize_trace_clone_programmatic_json,
212    serialize_trace_dependency_programmatic_json, serialize_trace_error_programmatic_json,
213    serialize_trace_export_programmatic_json, serialize_trace_file_programmatic_json,
214    serialize_trace_import_path_programmatic_json,
215};
216pub use sarif_output::{
217    annotate_sarif_results, build_duplication_sarif, build_grouped_duplication_sarif,
218    build_health_sarif,
219};
220pub use security_output::SecurityGateMode;
221pub use type_aware::{
222    SemanticCouplingOutcome, SemanticDeadCodeOutcome, SemanticInspectOutcome, TypeAwareError,
223    TypeAwareFileChanges, TypeAwareOutcome, TypeAwareSession, TypeAwareStatus,
224    discard_unverified_semantic_candidates, inspect_symbol as inspect_type_aware_symbol,
225    merge_type_aware_meta,
226    refine_configured_dead_code_results as refine_type_aware_results_with_config,
227    shutdown_type_aware_sidecars, status as type_aware_status,
228    symbol_impact as run_type_aware_symbol_impact, symbol_impact as type_aware_symbol_impact,
229    terminate_active_type_aware_sidecars, trace_symbol as run_type_aware_symbol_trace,
230    trace_symbol as trace_type_aware_symbol, type_coupling as analyze_type_coupling,
231};
232
233/// Long names of the analysis-affecting global CLI flags that
234/// [`AnalysisOptions`] mirrors for embedders.
235///
236/// A contract test in the CLI crate asserts this list stays in sync with the
237/// clap globals, so drift between the CLI surface and the programmatic
238/// options is caught at test time.
239pub const COMMON_ANALYSIS_OPTION_FLAGS: &[&str] = &[
240    "root",
241    "config",
242    "no-cache",
243    "threads",
244    "changed-since",
245    "diff-file",
246    "production",
247    "workspace",
248    "changed-workspaces",
249    "explain",
250    "allow-remote-extends",
251];
252
253/// Structured error surface for the programmatic API.
254#[derive(Debug, Clone, Serialize)]
255pub struct ProgrammaticError {
256    /// Human-readable description; also the `Display` output.
257    pub message: String,
258    /// Process exit code the CLI maps this failure to.
259    pub exit_code: u8,
260    /// Stable machine-readable code such as `FALLOW_INVALID_COVERAGE_PATH`.
261    pub code: Option<String>,
262    /// Optional remediation hint for the caller.
263    pub help: Option<String>,
264    /// Dotted path of the offending input, such as `health.coverage`.
265    pub context: Option<String>,
266}
267
268impl ProgrammaticError {
269    /// Create an error from the required message and exit code, with all
270    /// optional fields left empty.
271    #[must_use]
272    pub fn new(message: impl Into<String>, exit_code: u8) -> Self {
273        Self {
274            message: message.into(),
275            exit_code,
276            code: None,
277            help: None,
278            context: None,
279        }
280    }
281
282    /// Attach a remediation hint shown to the caller alongside the message.
283    #[must_use]
284    pub fn with_help(mut self, help: impl Into<String>) -> Self {
285        self.help = Some(help.into());
286        self
287    }
288
289    /// Attach a stable machine-readable code such as
290    /// `FALLOW_INVALID_COVERAGE_PATH`.
291    #[must_use]
292    pub fn with_code(mut self, code: impl Into<String>) -> Self {
293        self.code = Some(code.into());
294        self
295    }
296
297    /// Attach the dotted path of the offending input, such as
298    /// `health.coverage`.
299    #[must_use]
300    pub fn with_context(mut self, context: impl Into<String>) -> Self {
301        self.context = Some(context.into());
302        self
303    }
304}
305
306impl std::fmt::Display for ProgrammaticError {
307    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
308        write!(f, "{}", self.message)
309    }
310}
311
312impl std::error::Error for ProgrammaticError {}
313
314/// Shared options for all one-shot analyses.
315#[derive(Debug, Clone, Default)]
316pub struct AnalysisOptions {
317    /// Project root to analyze. `None` resolves to the current working
318    /// directory.
319    pub root: Option<PathBuf>,
320    /// Explicit config file path. `None` uses config discovery from the root.
321    pub config_path: Option<PathBuf>,
322    /// Permit `https://` config inheritance for this analysis call.
323    pub allow_remote_extends: bool,
324    /// Bypass the on-disk analysis cache for this call.
325    pub no_cache: bool,
326    /// Worker thread count. `None` picks the default; `Some(0)` is rejected.
327    pub threads: Option<usize>,
328    /// Explicit unified diff file that scopes changed-code analysis.
329    ///
330    /// Relative file paths resolve against the analysis root. Patch headers
331    /// are placed relative to that root or its Git repository toplevel.
332    /// A readable patch with foreign or ambiguous paths leaves the analysis
333    /// at full scope and records a `not-applied` diff-filter request outcome.
334    /// Empty and deletion-only patches apply an empty scope.
335    ///
336    /// A file that cannot be read fails the call with
337    /// `FALLOW_INVALID_DIFF_FILE`: the caller named it and can fix it.
338    /// An explicit file never falls back to `ambient_diff_file`.
339    pub diff_file: Option<PathBuf>,
340    /// Unified diff file inherited from the environment (`FALLOW_DIFF_FILE`)
341    /// rather than named by the caller. Read only when `diff_file` is unset.
342    ///
343    /// A file that cannot be read or placed does not fail the call. The
344    /// analysis runs at full scope and `request_outcomes["diff-filter"]` states
345    /// the reason, exactly as the CLI does for the same variable: a caller
346    /// cannot fix a CI environment it inherited (issue #2799).
347    pub ambient_diff_file: Option<PathBuf>,
348    /// Legacy convenience override. `true` forces production mode; `false`
349    /// defers to config unless `production_override` is set.
350    pub production: bool,
351    /// Explicit production override from an embedder option. `None` means
352    /// use the project config for the current analysis.
353    pub production_override: Option<bool>,
354    /// Git base reference that scopes analysis to files changed since it.
355    ///
356    /// A ref that does not resolve fails the call with
357    /// `FALLOW_CHANGED_FILES_FAILED`: the caller named it and can fix it.
358    pub changed_since: Option<String>,
359    /// Git base reference inherited from the environment
360    /// (`FALLOW_CHANGED_SINCE`) rather than named by the caller. Read only when
361    /// `changed_since` is unset.
362    ///
363    /// A ref that does not resolve does not fail the call. The analysis runs at
364    /// full scope and `request_outcomes["changed-since"]` states the reason,
365    /// exactly as the CLI does for `--changed-since`.
366    pub ambient_changed_since: Option<String>,
367    /// Ignore the per-package refs of `workspaces.changedSince` for this call,
368    /// as `--no-package-baselines` does on the CLI. Every workspace package is
369    /// then analyzed in full scope.
370    pub no_package_baselines: bool,
371    /// Restrict analysis to the named workspace packages.
372    pub workspace: Option<Vec<String>>,
373    /// Restrict analysis to workspaces changed since the given git reference.
374    pub changed_workspaces: Option<String>,
375    /// Include rule and metric explanations (`_meta`) in machine output.
376    pub explain: bool,
377    /// Optional project-wide TypeScript semantic analysis. Disabled by default
378    /// and never changes compiler or typed-lint ownership.
379    pub type_aware: TypeAwareOptions,
380    /// Caller-owned cooperative cancellation for this analysis.
381    ///
382    /// Setting the flag asks the analysis to stop at its next stage boundary
383    /// and return a `FALLOW_CANCELLED` error. `None`, the default, is an
384    /// analysis that always runs to completion.
385    ///
386    /// The field is on the shared options struct, but the entry points do not
387    /// all honor it, so read the one being called before relying on it:
388    ///
389    /// - Cancelled at the entry, on both sides of the per-file parse loop, and
390    ///   at each pipeline stage boundary: [`run_dead_code`],
391    ///   [`run_circular_dependencies`], [`run_boundary_violations`],
392    ///   [`run_architecture`],
393    ///   [`run_combined`], [`run_duplication`], [`run_feature_flags`], and the
394    ///   four trace routes.
395    /// - Cancelled only at the boundaries around config load and file
396    ///   discovery, because they are the whole run: [`run_project_info`] and
397    ///   [`run_list_boundaries`].
398    /// - Cancelled only at the entry, then run to completion:
399    ///   [`run_health`], which hands off to a runner that builds its own
400    ///   session below these options.
401    /// - Not cancellable at all: [`run_decision_surface`] and
402    ///   [`run_similar_code`].
403    ///
404    /// The stop is cooperative everywhere it exists, so it has no upper bound:
405    /// it is only as prompt as the longest stage holding no check, and
406    /// duplication detection and the dead-code detectors are each seconds of
407    /// uninterruptible work on a large repository. A caller that needs a
408    /// bounded stop has to run Fallow as a child process and kill it.
409    pub cancellation: Option<Arc<AtomicBool>>,
410}
411
412/// Typed options for Fallow's optional TypeScript semantic companion.
413#[derive(Debug, Clone, Default, PartialEq, Eq)]
414pub struct TypeAwareOptions {
415    /// `Some(true)` turns on TypeScript semantic refinement for this call, as
416    /// `--type-aware` does. `Some(false)` keeps the call syntactic, as
417    /// `--no-type-aware` does, also when `FALLOW_TYPE_AWARE` or the config
418    /// turns it on. `None` follows `FALLOW_TYPE_AWARE` and the config. The
419    /// typed runners cannot run type-aware analysis from those layers, so they
420    /// return [`TYPE_AWARE_CONFIG_UNSUPPORTED`] when a layer turns it on.
421    pub enabled: Option<bool>,
422    /// Explicit TypeScript project paths handed to the semantic sidecar.
423    /// Empty defers to project discovery.
424    pub projects: Vec<PathBuf>,
425    /// How strictly semantic completion is required before results are kept.
426    pub require: fallow_config::TypeAwareRequire,
427}
428
429impl TypeAwareOptions {
430    /// The caller requested type-aware analysis for this call.
431    #[must_use]
432    pub const fn requested(&self) -> bool {
433        matches!(self.enabled, Some(true))
434    }
435}
436
437/// Issue-type filters for the dead-code analysis.
438///
439/// Each flag opts one issue type into the report. When no flag is enabled the
440/// analysis reports every issue type.
441#[derive(Debug, Clone, Default)]
442pub struct DeadCodeFilters {
443    /// Files never reached from any entry point.
444    pub unused_files: bool,
445    /// Exported symbols never imported elsewhere.
446    pub unused_exports: bool,
447    /// Declared dependencies never imported.
448    pub unused_deps: bool,
449    /// Exported types never referenced.
450    pub unused_types: bool,
451    /// Exported APIs that expose non-exported types.
452    pub private_type_leaks: bool,
453    /// Exports marked `@deprecated` that are still in use.
454    pub deprecated_exports_in_use: bool,
455    /// Enum members never read.
456    pub unused_enum_members: bool,
457    /// Class members never used outside their declaration.
458    pub unused_class_members: bool,
459    /// Store members (for example Pinia) never used outside the store.
460    pub unused_store_members: bool,
461    /// `inject` calls with no matching `provide`.
462    pub unprovided_injects: bool,
463    /// Components never rendered by any template or JSX.
464    pub unrendered_components: bool,
465    /// Declared component props never used.
466    pub unused_component_props: bool,
467    /// Review optional props omitted by known reachable callers.
468    pub absent_component_props: bool,
469    /// Declared component emits never used.
470    pub unused_component_emits: bool,
471    /// Declared component inputs never bound.
472    pub unused_component_inputs: bool,
473    /// Declared component outputs never listened to.
474    pub unused_component_outputs: bool,
475    /// Svelte component events never listened to.
476    pub unused_svelte_events: bool,
477    /// Effect layers composed only into unused layers.
478    pub unused_effect_layers: bool,
479    /// Effect tagged errors that no code produces.
480    pub unused_tagged_errors: bool,
481    /// `catchTag` handlers for a tag that no code produces.
482    pub unproduced_catch_tags: bool,
483    /// Server actions never invoked.
484    pub unused_server_actions: bool,
485    /// `load` data keys never read by the consuming page.
486    pub unused_load_data_keys: bool,
487    /// Imports that do not resolve to a file or package.
488    pub unresolved_imports: bool,
489    /// Imported packages missing from the dependency manifest.
490    pub unlisted_deps: bool,
491    /// The same symbol exported more than once.
492    pub duplicate_exports: bool,
493    /// Circular import chains.
494    pub circular_deps: bool,
495    /// Cycles formed through re-export chains.
496    pub re_export_cycles: bool,
497    /// Dependency cycles between workspace packages.
498    pub package_cycles: bool,
499    /// Imports that cross configured architecture boundaries.
500    pub boundary_violations: bool,
501    /// Violations of configured dependency policy rules.
502    pub policy_violations: bool,
503    /// Suppression comments that no longer match a finding.
504    pub stale_suppressions: bool,
505    /// Catalog entries never referenced by a workspace package.
506    pub unused_catalog_entries: bool,
507    /// Catalog groups that contain no entries.
508    pub empty_catalog_groups: bool,
509    /// `catalog:` references without a matching catalog entry.
510    pub unresolved_catalog_references: bool,
511    /// Dependency overrides that never affect a resolved package.
512    pub unused_dependency_overrides: bool,
513    /// Dependency overrides that cannot apply as written.
514    pub misconfigured_dependency_overrides: bool,
515}
516
517impl DeadCodeFilters {
518    /// Whether the report keeps dependency findings: no filter is active, or
519    /// `unused_deps` or `unlisted_deps` is one of the active filters. The CLI
520    /// applies the same rule to its `--unused-deps` and `--unlisted-deps`.
521    pub(crate) fn reports_dependency_findings(&self) -> bool {
522        !self.any_active() || self.unused_deps || self.unlisted_deps
523    }
524
525    fn any_active(&self) -> bool {
526        self.unused_files
527            || self.unused_exports
528            || self.unused_deps
529            || self.unused_types
530            || self.private_type_leaks
531            || self.deprecated_exports_in_use
532            || self.unused_enum_members
533            || self.unused_class_members
534            || self.unused_store_members
535            || self.unprovided_injects
536            || self.unrendered_components
537            || self.unused_component_props
538            || self.absent_component_props
539            || self.unused_component_emits
540            || self.unused_component_inputs
541            || self.unused_component_outputs
542            || self.unused_svelte_events
543            || self.unused_effect_layers
544            || self.unused_tagged_errors
545            || self.unproduced_catch_tags
546            || self.unused_server_actions
547            || self.unused_load_data_keys
548            || self.unresolved_imports
549            || self.unlisted_deps
550            || self.duplicate_exports
551            || self.circular_deps
552            || self.re_export_cycles
553            || self.package_cycles
554            || self.boundary_violations
555            || self.policy_violations
556            || self.stale_suppressions
557            || self.unused_catalog_entries
558            || self.empty_catalog_groups
559            || self.unresolved_catalog_references
560            || self.unused_dependency_overrides
561            || self.misconfigured_dependency_overrides
562    }
563
564    /// Enable the issue filter addressed by a shared registry selector.
565    ///
566    /// Returns `false` when the selector is not registered for dead-code
567    /// filtering. Callers that expose user input should surface their own
568    /// validation error with the accepted registry values.
569    pub fn enable_registry_selector(&mut self, selector: &str) -> bool {
570        let Some(flag) = fallow_types::issue_meta::MCP_ISSUE_TYPE_FLAGS
571            .iter()
572            .find_map(|&(name, flag)| (name == selector).then_some(flag))
573        else {
574            return false;
575        };
576        self.enable_cli_filter_flag(flag);
577        true
578    }
579
580    fn enable_cli_filter_flag(&mut self, flag: &str) {
581        match flag {
582            "--unused-files" => self.unused_files = true,
583            "--unused-exports" => self.unused_exports = true,
584            "--unused-types" => self.unused_types = true,
585            "--private-type-leaks" => self.private_type_leaks = true,
586            "--deprecated-exports-in-use" => self.deprecated_exports_in_use = true,
587            "--unused-deps" => self.unused_deps = true,
588            "--unused-enum-members" => self.unused_enum_members = true,
589            "--unused-class-members" => self.unused_class_members = true,
590            "--unused-store-members" => self.unused_store_members = true,
591            "--unprovided-injects" => self.unprovided_injects = true,
592            "--unrendered-components" => self.unrendered_components = true,
593            "--unused-component-props" => self.unused_component_props = true,
594            "--absent-component-props" => self.absent_component_props = true,
595            "--unused-component-emits" => self.unused_component_emits = true,
596            "--unused-component-inputs" => self.unused_component_inputs = true,
597            "--unused-component-outputs" => self.unused_component_outputs = true,
598            "--unused-svelte-events" => self.unused_svelte_events = true,
599            "--unused-effect-layers" => self.unused_effect_layers = true,
600            "--unused-tagged-errors" => self.unused_tagged_errors = true,
601            "--unproduced-catch-tags" => self.unproduced_catch_tags = true,
602            "--unused-server-actions" => self.unused_server_actions = true,
603            "--unused-load-data-keys" => self.unused_load_data_keys = true,
604            "--unresolved-imports" => self.unresolved_imports = true,
605            "--unlisted-deps" => self.unlisted_deps = true,
606            "--duplicate-exports" => self.duplicate_exports = true,
607            "--circular-deps" => self.circular_deps = true,
608            "--re-export-cycles" => self.re_export_cycles = true,
609            "--package-cycles" => self.package_cycles = true,
610            "--boundary-violations" => self.boundary_violations = true,
611            "--policy-violations" => self.policy_violations = true,
612            "--stale-suppressions" => self.stale_suppressions = true,
613            "--unused-catalog-entries" => self.unused_catalog_entries = true,
614            "--empty-catalog-groups" => self.empty_catalog_groups = true,
615            "--unresolved-catalog-references" => self.unresolved_catalog_references = true,
616            "--unused-dependency-overrides" => self.unused_dependency_overrides = true,
617            "--misconfigured-dependency-overrides" => {
618                self.misconfigured_dependency_overrides = true;
619            }
620            _ => unreachable!("registry emitted unsupported dead-code filter flag: {flag}"),
621        }
622    }
623}
624
625/// Options for dead-code-oriented analyses.
626#[derive(Debug, Clone, Default)]
627pub struct DeadCodeOptions {
628    /// Shared analysis options.
629    pub analysis: AnalysisOptions,
630    /// Issue-type selection; everything is reported when no filter is set.
631    pub filters: DeadCodeFilters,
632    /// Restrict findings to these files when non-empty.
633    pub files: Vec<PathBuf>,
634    /// Also report unused exports declared in entry-point files.
635    pub include_entry_exports: bool,
636    /// Keep the export and member findings of unused files (`--show-cascade`).
637    pub show_cascade: bool,
638    /// Report only the findings with these `finding_id` values. The filter
639    /// runs last, after the baseline, and the output then carries
640    /// `finding_id_query`. Empty reports every finding. Only
641    /// [`run_dead_code`] and [`run_dead_code_with_baseline`] accept it.
642    pub finding_ids: Vec<String>,
643}
644
645/// Options for the analysis of `fallow architecture`: import cycles, boundary
646/// violations and rule-pack policy violations.
647///
648/// No selector reports every architecture issue type. Each selector keeps one
649/// kind, as `--cycles`, `--boundaries` and `--policy` do on the CLI.
650#[derive(Debug, Clone, Default)]
651pub struct ArchitectureOptions {
652    /// Shared analysis options.
653    pub analysis: AnalysisOptions,
654    /// Restrict findings to these files when non-empty.
655    pub files: Vec<PathBuf>,
656    /// Import cycles: circular dependencies, re-export cycles and workspace
657    /// package cycles.
658    pub cycles: bool,
659    /// Boundary violations, boundary coverage and forbidden calls.
660    pub boundaries: bool,
661    /// Rule-pack policy violations.
662    pub policy: bool,
663}
664
665impl ArchitectureOptions {
666    /// The dead-code issue-type filters that the selectors stand for.
667    #[must_use]
668    pub fn dead_code_filters(&self) -> DeadCodeFilters {
669        let all = !(self.cycles || self.boundaries || self.policy);
670        DeadCodeFilters {
671            circular_deps: self.cycles || all,
672            re_export_cycles: self.cycles || all,
673            package_cycles: self.cycles || all,
674            boundary_violations: self.boundaries || all,
675            policy_violations: self.policy || all,
676            ..DeadCodeFilters::default()
677        }
678    }
679
680    /// The dead-code options that run this analysis.
681    #[must_use]
682    pub fn dead_code_options(&self) -> DeadCodeOptions {
683        DeadCodeOptions {
684            analysis: self.analysis.clone(),
685            filters: self.dead_code_filters(),
686            files: self.files.clone(),
687            include_entry_exports: false,
688            show_cascade: false,
689            finding_ids: Vec::new(),
690        }
691    }
692}
693
694/// Options for changed-code audit analysis.
695#[derive(Debug, Clone, Default)]
696pub struct AuditOptions {
697    /// Shared analysis options.
698    pub analysis: AnalysisOptions,
699    /// Git base reference for the changed-code comparison. `None` lets the
700    /// audit detect a base itself.
701    pub base: Option<String>,
702    /// Force production mode for every audit domain.
703    pub production: bool,
704    /// Production override for the dead-code domain; `None` defers to config.
705    pub production_dead_code: Option<bool>,
706    /// Production override for the health domain; `None` defers to config.
707    pub production_health: Option<bool>,
708    /// Production override for the duplication domain; `None` defers to
709    /// config.
710    pub production_dupes: Option<bool>,
711    /// Run the dead-code domain; `None` defers to `audit.deadCode` in config,
712    /// which defaults to on. When off, the verdict comes from the domains that
713    /// run.
714    pub dead_code: Option<bool>,
715    /// Run the health domain; `None` defers to `audit.health` in config,
716    /// which defaults to on.
717    pub health: Option<bool>,
718    /// Run the duplication domain; `None` defers to `audit.dupes` in config,
719    /// which defaults to on.
720    pub duplication: Option<bool>,
721    /// `Some(false)` skips CSS / styling analysis, as `--no-css` does. `None`
722    /// and `Some(true)` defer to `audit.css` in config, which defaults to on.
723    pub css: Option<bool>,
724    /// Enable deep cross-file CSS analysis; `None` defers to `audit.cssDeep`
725    /// in config, which defaults to on. It runs only when CSS analysis runs.
726    pub css_deep: Option<bool>,
727    /// Gate mode deciding which findings fail the audit; `None` defers to
728    /// `audit.gate` in config, which defaults to `new-only`.
729    pub gate: Option<fallow_config::AuditGate>,
730    /// How a worsened complexity finding affects the verdict; `None` defers
731    /// to `audit.worsened` in config, which defaults to `warn`.
732    pub worsened: Option<fallow_config::AuditWorsened>,
733    /// Fail the gate when a changed function exceeds this CRAP score.
734    pub max_crap: Option<f64>,
735    /// Test coverage report path for coverage-aware findings.
736    pub coverage: Option<PathBuf>,
737    /// Absolute path prefix the coverage report recorded its files under.
738    pub coverage_root: Option<PathBuf>,
739    /// Also report unused exports declared in entry-point files.
740    pub include_entry_exports: bool,
741    /// Keep the export and member findings of unused files (`--show-cascade`).
742    pub show_cascade: bool,
743    /// Runtime coverage capture merged into the audit.
744    pub runtime_coverage: Option<PathBuf>,
745    /// Minimum recorded invocations for a code path to count as hot.
746    pub min_invocations_hot: u64,
747}
748
749/// Options for bare combined analysis through the programmatic API.
750#[derive(Debug, Clone)]
751pub struct CombinedOptions {
752    /// Shared analysis options.
753    pub analysis: AnalysisOptions,
754    /// Run the dead-code domain.
755    pub dead_code: bool,
756    /// Run the duplication domain.
757    pub duplication: bool,
758    /// Run the health domain.
759    pub health: bool,
760    /// Also report unused exports declared in entry-point files.
761    pub include_entry_exports: bool,
762    /// Keep the export and member findings of unused files (`--show-cascade`).
763    pub show_cascade: bool,
764    /// Options for the duplication domain.
765    pub duplication_options: DuplicationOptions,
766    /// Options for the health domain.
767    pub health_options: ComplexityOptions,
768}
769
770impl Default for CombinedOptions {
771    fn default() -> Self {
772        Self {
773            analysis: AnalysisOptions::default(),
774            dead_code: true,
775            duplication: true,
776            health: true,
777            include_entry_exports: false,
778            show_cascade: false,
779            duplication_options: DuplicationOptions::default(),
780            health_options: ComplexityOptions::default(),
781        }
782    }
783}
784
785/// Options for changed-code decision-surface analysis.
786#[derive(Debug, Clone, Default)]
787pub struct DecisionSurfaceOptions {
788    /// Shared analysis options.
789    pub analysis: AnalysisOptions,
790    /// Git base reference for the changed-code comparison. `None` lets the
791    /// analysis detect a base itself.
792    pub base: Option<String>,
793    /// Cap on the number of decisions surfaced.
794    pub max_decisions: Option<usize>,
795}
796
797/// Options for feature-flag analysis.
798#[derive(Debug, Clone, Default)]
799pub struct FeatureFlagsOptions {
800    /// Shared analysis options.
801    pub analysis: AnalysisOptions,
802    /// Cap on the number of reported flags. With `retirement`, it also caps
803    /// the retirement rows.
804    pub top: Option<usize>,
805    /// Retirement report options. `None` leaves the `retirement` block out.
806    pub retirement: Option<FeatureFlagsRetirementOptions>,
807}
808
809/// Options of the flag retirement report, the `--retirement` mode of
810/// `fallow flags`.
811#[derive(Debug, Clone, Default)]
812pub struct FeatureFlagsRetirementOptions {
813    /// How to measure flag age.
814    pub flag_age: fallow_types::flag_retirement::FlagAgeMode,
815    /// Vendor flag export to read, like `--flag-state`.
816    pub flag_state: Option<std::path::PathBuf>,
817    /// Keep only rows with one of these reasons. Empty keeps all rows.
818    pub reasons: Vec<fallow_types::flag_retirement::RetirementReason>,
819    /// Row order.
820    pub sort: fallow_engine::flag_retirement::RetirementSort,
821    /// Keep only rows at least this many days old.
822    pub min_age_days: Option<u64>,
823    /// Report the flags older than this many days in `max_flag_age`.
824    pub max_flag_age: Option<u64>,
825}
826
827/// Programmatic duplication mode selection.
828#[derive(Debug, Clone, Copy, Default)]
829pub enum DuplicationMode {
830    /// Preserve all tokens, including identifier names and literal values
831    /// (Type-1 clones only).
832    Strict,
833    /// Default mode, equivalent to strict for AST-based tokenization.
834    #[default]
835    Mild,
836    /// Blind string literal values while preserving structure.
837    Weak,
838    /// Blind all identifiers and literal values for structural (Type-2)
839    /// detection.
840    Semantic,
841}
842
843/// Options for duplication analysis.
844#[derive(Debug, Clone, Default)]
845pub struct DuplicationOptions {
846    /// Shared analysis options.
847    pub analysis: AnalysisOptions,
848    /// Detection mode; `None` defers to the project config.
849    pub mode: Option<DuplicationMode>,
850    /// Detect function-scoped near-miss clones in addition to exact clones.
851    /// `None` defers to the project config.
852    pub near: Option<bool>,
853    /// Minimum number of tokens for a clone.
854    pub min_tokens: Option<usize>,
855    /// Minimum number of lines for a clone.
856    pub min_lines: Option<usize>,
857    /// Minimum number of occurrences before a clone group is reported.
858    /// Values below 2 are silently treated as 2 by the engine-facing adapter.
859    pub min_occurrences: Option<usize>,
860    /// Maximum allowed duplication percentage before the gate fails; 0 means
861    /// no limit. `None` defers to the project config.
862    pub threshold: Option<f64>,
863    /// Only report cross-directory duplicates. `None` defers to the project
864    /// config.
865    pub skip_local: Option<bool>,
866    /// Omit clone instances whose path is a symlink or lies under a symlinked
867    /// directory. `None` defers to the project config.
868    pub ignore_symlinks: Option<bool>,
869    /// Match clones across languages. `None` defers to the project config.
870    pub cross_language: Option<bool>,
871    /// Exclude module wiring from clone detection. `None` defers to the project
872    /// config.
873    pub ignore_imports: Option<bool>,
874    /// Cap on the number of reported clone groups.
875    pub top: Option<usize>,
876    /// Emit the verbatim source text on each clone instance. `None` keeps the
877    /// text, matching `fallow dupes` without `--no-fragments`; `Some(false)`
878    /// yields a location-only payload.
879    pub include_fragments: Option<bool>,
880}
881
882/// Options for local advisory similar-code discovery.
883#[derive(Debug, Clone, Default)]
884pub struct SimilarCodeOptions {
885    /// Shared project, config, cache, diff, and workspace options.
886    pub analysis: AnalysisOptions,
887    /// Model-specific cosine floor. `None` uses `similarCode.threshold`.
888    pub threshold: Option<f64>,
889    /// Minimum source lines per function. `None` uses
890    /// `similarCode.minLines`.
891    pub min_lines: Option<usize>,
892    /// Cap on displayed candidates after the full bounded comparison.
893    pub top: Option<usize>,
894    /// Restrict work and reported pairs to those where at least one side matches
895    /// one of these project-relative files. Deterministic background context is
896    /// retained only within the bounded scoped comparison budget.
897    pub files: Vec<PathBuf>,
898    /// Exact companion binary resolved and signature-verified by an official
899    /// distribution adapter such as the Node loader. `None` uses trusted
900    /// sibling discovery. Project config, PATH commands, and remote providers
901    /// must never populate this field.
902    #[doc(hidden)]
903    pub adapter_provider_path: Option<PathBuf>,
904}
905
906/// Options for one similar-code candidate inspection packet.
907#[derive(Debug, Clone)]
908pub struct SimilarCodeInspectOptions {
909    /// Shared project and configuration options used only for source validation
910    /// and deterministic enrichment. Inspect never reruns provider retrieval.
911    pub analysis: AnalysisOptions,
912    /// Bounded candidate handoff selected from the original discovery output.
913    pub snapshot: fallow_output::SimilarCodeCandidateSnapshot,
914}
915
916/// Options for export trace analysis.
917#[derive(Debug, Clone, Default)]
918pub struct TraceExportOptions {
919    /// Shared analysis options.
920    pub analysis: AnalysisOptions,
921    /// Path of the module that declares the export.
922    pub file: String,
923    /// Name of the export to trace.
924    pub export_name: String,
925}
926
927/// Options for file trace analysis.
928#[derive(Debug, Clone, Default)]
929pub struct TraceFileOptions {
930    /// Shared analysis options.
931    pub analysis: AnalysisOptions,
932    /// Path of the file to trace.
933    pub file: String,
934}
935
936/// Options for shortest-import-path trace analysis.
937#[derive(Debug, Clone, Default)]
938pub struct TraceImportPathOptions {
939    /// Shared analysis options.
940    pub analysis: AnalysisOptions,
941    /// Path of the module the walk starts from.
942    pub from: String,
943    /// Path of the module the walk is looking for.
944    pub to: String,
945    /// Follow only static imports that carry a runtime value, so the route
946    /// explains why `to` loads before `from` runs.
947    pub eager_only: bool,
948}
949
950/// Options for stack-trace frame resolution.
951#[derive(Debug, Clone, Default)]
952pub struct TraceErrorOptions {
953    /// Shared analysis options.
954    pub analysis: AnalysisOptions,
955    /// The runtime stack trace, verbatim.
956    pub trace: String,
957    /// Where the trace came from, reported back as the payload's `source`.
958    pub source: String,
959}
960
961/// Options for dependency trace analysis.
962#[derive(Debug, Clone, Default)]
963pub struct TraceDependencyOptions {
964    /// Shared analysis options.
965    pub analysis: AnalysisOptions,
966    /// Package whose importers are traced.
967    pub package_name: String,
968    /// The per-specifier usage to report. `None` keeps the output equal to
969    /// `fallow dead-code --trace-dependency`, with no `usage` object.
970    pub usage: Option<DependencyUsageQuery>,
971}
972
973/// Duplicate-code trace target.
974#[derive(Debug, Clone, PartialEq, Eq)]
975pub enum TraceCloneTarget {
976    /// Select the clone group covering this file and line.
977    Location {
978        /// Path of the file containing the clone instance.
979        file: String,
980        /// One-based line inside the clone instance.
981        line: usize,
982    },
983    /// Select the clone group by its fingerprint.
984    Fingerprint(String),
985}
986
987/// Options for duplicate-code trace analysis.
988#[derive(Debug, Clone)]
989pub struct TraceCloneOptions {
990    /// Duplication options controlling detection before the trace.
991    pub duplication: DuplicationOptions,
992    /// Clone group to trace.
993    pub target: TraceCloneTarget,
994}
995
996/// Sort criteria for complexity findings.
997#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
998pub enum ComplexitySort {
999    /// Sort by cyclomatic complexity (default).
1000    #[default]
1001    Cyclomatic,
1002    /// Sort by cognitive complexity.
1003    Cognitive,
1004    /// Sort by function length in lines.
1005    Lines,
1006    /// Sort by finding severity.
1007    Severity,
1008}
1009
1010/// Privacy mode for ownership-aware hotspot output.
1011#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1012pub enum OwnershipEmailMode {
1013    /// Show the raw email address as it appears in git history.
1014    Raw,
1015    /// Show only the local part before the `@` (default).
1016    #[default]
1017    Handle,
1018    /// Show a stable non-cryptographic pseudonym derived from the raw email.
1019    Anonymized,
1020    /// Legacy spelling retained for embedders that already pass `hash`.
1021    Hash,
1022}
1023
1024/// Effort filter for refactoring targets.
1025#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1026pub enum TargetEffort {
1027    /// Low estimated refactoring effort.
1028    Low,
1029    /// Medium estimated refactoring effort.
1030    Medium,
1031    /// High estimated refactoring effort.
1032    High,
1033}
1034
1035/// Options for complexity / health analysis.
1036#[derive(Debug, Clone, Default)]
1037pub struct ComplexityOptions {
1038    /// Shared analysis options.
1039    pub analysis: AnalysisOptions,
1040    /// Override for the cyclomatic complexity threshold.
1041    pub max_cyclomatic: Option<u16>,
1042    /// Override for the cognitive complexity threshold.
1043    pub max_cognitive: Option<u16>,
1044    /// Override for the CRAP score threshold.
1045    pub max_crap: Option<f64>,
1046    /// Cap on the number of reported findings.
1047    pub top: Option<usize>,
1048    /// Sort order for complexity findings.
1049    pub sort: ComplexitySort,
1050    /// Include the per-metric complexity breakdown with each finding.
1051    pub complexity_breakdown: bool,
1052    /// Request the complexity findings section.
1053    pub complexity: bool,
1054    /// Request the per-file score section.
1055    pub file_scores: bool,
1056    /// Request the coverage-gap section.
1057    pub coverage_gaps: bool,
1058    /// Request the churn hotspot section.
1059    pub hotspots: bool,
1060    /// Include ownership data with hotspots; implies the hotspot section.
1061    pub ownership: bool,
1062    /// Email privacy mode for ownership output; implies ownership when set.
1063    pub ownership_emails: Option<OwnershipEmailMode>,
1064    /// Request the refactoring targets section.
1065    pub targets: bool,
1066    /// Include CSS / styling health.
1067    pub css: bool,
1068    /// Enable deep cross-file CSS analysis.
1069    pub css_deep: bool,
1070    /// Filter refactoring targets by estimated effort; implies the targets
1071    /// section when set.
1072    pub effort: Option<TargetEffort>,
1073    /// Request the overall health score.
1074    pub score: bool,
1075    /// Git time window (for example `30d`) for churn-based sections.
1076    pub since: Option<String>,
1077    /// Minimum commit count for a file to count as a hotspot.
1078    pub min_commits: Option<u32>,
1079    /// Test coverage report path for coverage-aware sections.
1080    pub coverage: Option<PathBuf>,
1081    /// Absolute path prefix the coverage report recorded its files under.
1082    pub coverage_root: Option<PathBuf>,
1083    /// The coverage map was recorded against a different checkout of this
1084    /// project, so function line numbers may have drifted arbitrarily.
1085    /// Set internally by the audit base-worktree pass; leave `false` for
1086    /// same-checkout coverage.
1087    pub coverage_relocated: bool,
1088}
1089
1090/// Health threshold overrides accepted by the programmatic API.
1091#[derive(Debug, Clone, Copy, Default, PartialEq)]
1092pub struct ComplexityThresholdOverrides {
1093    /// Override for the cyclomatic complexity threshold.
1094    pub max_cyclomatic: Option<u16>,
1095    /// Override for the cognitive complexity threshold.
1096    pub max_cognitive: Option<u16>,
1097    /// Override for the CRAP score threshold.
1098    pub max_crap: Option<f64>,
1099}
1100
1101/// Coverage inputs accepted by the programmatic API.
1102#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1103pub struct ComplexityCoverageInputs<'a> {
1104    /// Test coverage report path.
1105    pub coverage: Option<&'a Path>,
1106    /// Absolute path prefix the coverage report recorded its files under.
1107    pub coverage_root: Option<&'a Path>,
1108    /// The coverage map was recorded against a different checkout of this
1109    /// project; tolerate arbitrary line drift for unambiguous name matches.
1110    pub coverage_relocated: bool,
1111}
1112
1113/// Input for deriving effective health sections from API-owned flags.
1114#[derive(Debug, Clone)]
1115pub struct HealthSectionOptions {
1116    /// Requested output format; `Badge` implies the score section.
1117    pub output: fallow_types::output_format::OutputFormat,
1118    /// The complexity findings section was requested.
1119    pub complexity: bool,
1120    /// The per-file score section was requested.
1121    pub file_scores: bool,
1122    /// The coverage-gap section was requested.
1123    pub coverage_gaps: bool,
1124    /// The churn hotspot section was requested.
1125    pub hotspots: bool,
1126    /// The refactoring targets section was requested.
1127    pub targets: bool,
1128    /// CSS / styling health was requested.
1129    pub css: bool,
1130    /// The overall health score was requested.
1131    pub score: bool,
1132    /// A score gate is active; implies the score section.
1133    pub score_gate: bool,
1134    /// A snapshot write was requested; forces full hidden inputs.
1135    pub snapshot_requested: bool,
1136    /// Trend output was requested; implies score and hotspot inputs.
1137    pub trend: bool,
1138}
1139
1140/// Derived section selection for health runs.
1141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1142pub struct DerivedHealthSections {
1143    /// At least one section was explicitly requested.
1144    pub any_section: bool,
1145    /// Emit the complexity findings section.
1146    pub complexity: bool,
1147    /// Compute the per-file score section.
1148    pub file_scores: bool,
1149    /// Emit the coverage-gap section.
1150    pub coverage_gaps: bool,
1151    /// Compute the churn hotspot section.
1152    pub hotspots: bool,
1153    /// Emit the refactoring targets section.
1154    pub targets: bool,
1155    /// Include CSS / styling health.
1156    pub css: bool,
1157    /// Compute the overall health score.
1158    pub score: bool,
1159    /// Compute full inputs even for sections that are not emitted.
1160    pub force_full: bool,
1161    /// Only the score should be printed.
1162    pub score_only_output: bool,
1163}
1164
1165/// Input for deriving effective programmatic complexity sections.
1166#[derive(Debug, Clone)]
1167pub struct ComplexitySectionOptions {
1168    /// The complexity findings section was requested.
1169    pub complexity: bool,
1170    /// The per-file score section was requested.
1171    pub file_scores: bool,
1172    /// The coverage-gap section was requested.
1173    pub coverage_gaps: bool,
1174    /// The churn hotspot section was requested.
1175    pub hotspots: bool,
1176    /// Ownership data was requested; implies the hotspot section.
1177    pub ownership: bool,
1178    /// The refactoring targets section was requested.
1179    pub targets: bool,
1180    /// CSS / styling health was requested.
1181    pub css: bool,
1182    /// The overall health score was requested.
1183    pub score: bool,
1184}
1185
1186/// Derived section selection for programmatic health / complexity runs.
1187#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1188pub struct DerivedComplexityOptions {
1189    /// At least one section was explicitly requested.
1190    pub any_section: bool,
1191    /// Emit the complexity findings section.
1192    pub complexity: bool,
1193    /// Compute the per-file score section.
1194    pub file_scores: bool,
1195    /// Emit the coverage-gap section.
1196    pub coverage_gaps: bool,
1197    /// Compute the churn hotspot section.
1198    pub hotspots: bool,
1199    /// Include ownership data with hotspots.
1200    pub ownership: bool,
1201    /// Emit the refactoring targets section.
1202    pub targets: bool,
1203    /// Compute full inputs even for sections that are not emitted.
1204    pub force_full: bool,
1205    /// Only the score should be printed.
1206    pub score_only_output: bool,
1207    /// Compute the overall health score.
1208    pub score: bool,
1209}
1210
1211/// Normalized programmatic complexity / health inputs owned by `fallow-api`.
1212#[derive(Debug, Clone, PartialEq)]
1213pub struct ComplexityRunOptions<'a> {
1214    /// Complexity threshold overrides.
1215    pub thresholds: ComplexityThresholdOverrides,
1216    /// Cap on the number of reported findings.
1217    pub top: Option<usize>,
1218    /// Sort order for complexity findings.
1219    pub sort: ComplexitySort,
1220    /// Include the per-metric complexity breakdown with each finding.
1221    pub complexity_breakdown: bool,
1222    /// Derived effective section selection.
1223    pub sections: DerivedComplexityOptions,
1224    /// Email privacy mode for ownership output.
1225    pub ownership_emails: Option<OwnershipEmailMode>,
1226    /// Filter refactoring targets by estimated effort.
1227    pub effort: Option<TargetEffort>,
1228    /// Include CSS / styling health.
1229    pub css: bool,
1230    /// Enable deep cross-file CSS analysis.
1231    pub css_deep: bool,
1232    /// Git time window (for example `30d`) for churn-based sections.
1233    pub since: Option<&'a str>,
1234    /// Minimum commit count for a file to count as a hotspot.
1235    pub min_commits: Option<u32>,
1236    /// Test coverage inputs.
1237    pub coverage_inputs: ComplexityCoverageInputs<'a>,
1238}
1239
1240/// Derive effective health section flags for API consumers.
1241#[must_use]
1242pub fn derive_health_sections(options: &HealthSectionOptions) -> DerivedHealthSections {
1243    let score = options.score
1244        || options.score_gate
1245        || options.trend
1246        || matches!(
1247            options.output,
1248            fallow_types::output_format::OutputFormat::Badge
1249        );
1250    let any_section = options.complexity
1251        || options.file_scores
1252        || options.coverage_gaps
1253        || options.hotspots
1254        || options.targets
1255        || score;
1256    let effective_score = if any_section { score } else { true } || options.snapshot_requested;
1257    let force_full = options.snapshot_requested || effective_score;
1258
1259    DerivedHealthSections {
1260        any_section,
1261        complexity: if any_section {
1262            options.complexity
1263        } else {
1264            true
1265        },
1266        file_scores: if any_section {
1267            options.file_scores
1268        } else {
1269            true
1270        } || force_full,
1271        coverage_gaps: if any_section {
1272            options.coverage_gaps
1273        } else {
1274            false
1275        },
1276        hotspots: if any_section { options.hotspots } else { true }
1277            || options.snapshot_requested
1278            || options.trend,
1279        targets: if any_section { options.targets } else { true },
1280        css: options.css,
1281        score: effective_score,
1282        force_full,
1283        score_only_output: is_health_score_only_output(options, score),
1284    }
1285}
1286
1287/// Derive effective programmatic health / complexity section flags.
1288#[must_use]
1289pub fn derive_complexity_sections(options: &ComplexitySectionOptions) -> DerivedComplexityOptions {
1290    let requested_hotspots = options.hotspots || options.ownership;
1291    let sections = derive_health_sections(&HealthSectionOptions {
1292        output: fallow_types::output_format::OutputFormat::Human,
1293        complexity: options.complexity,
1294        file_scores: options.file_scores,
1295        coverage_gaps: options.coverage_gaps,
1296        hotspots: requested_hotspots,
1297        targets: options.targets,
1298        css: options.css,
1299        score: options.score,
1300        score_gate: false,
1301        snapshot_requested: false,
1302        trend: false,
1303    });
1304
1305    DerivedComplexityOptions {
1306        any_section: sections.any_section,
1307        complexity: sections.complexity,
1308        file_scores: sections.file_scores,
1309        coverage_gaps: sections.coverage_gaps,
1310        hotspots: sections.hotspots,
1311        ownership: options.ownership && sections.hotspots,
1312        targets: sections.targets,
1313        force_full: sections.force_full,
1314        score_only_output: sections.score_only_output,
1315        score: sections.score,
1316    }
1317}
1318
1319/// Derive effective programmatic health / complexity section flags.
1320#[must_use]
1321pub fn derive_complexity_options(options: &ComplexityOptions) -> DerivedComplexityOptions {
1322    derive_complexity_sections(&complexity_section_options(options))
1323}
1324
1325/// Normalize public API complexity options into engine-owned run contracts.
1326#[must_use]
1327pub fn derive_complexity_run_options(options: &ComplexityOptions) -> ComplexityRunOptions<'_> {
1328    ComplexityRunOptions {
1329        thresholds: ComplexityThresholdOverrides {
1330            max_cyclomatic: options.max_cyclomatic,
1331            max_cognitive: options.max_cognitive,
1332            max_crap: options.max_crap,
1333        },
1334        top: options.top,
1335        sort: options.sort,
1336        complexity_breakdown: options.complexity_breakdown,
1337        sections: derive_complexity_options(options),
1338        ownership_emails: options.ownership_emails,
1339        effort: options.effort,
1340        css: options.css,
1341        css_deep: options.css_deep,
1342        since: options.since.as_deref(),
1343        min_commits: options.min_commits,
1344        coverage_inputs: ComplexityCoverageInputs {
1345            coverage: options.coverage.as_deref(),
1346            coverage_root: options.coverage_root.as_deref(),
1347            coverage_relocated: options.coverage_relocated,
1348        },
1349    }
1350}
1351
1352/// Validate programmatic complexity / health inputs before invoking a concrete
1353/// runner.
1354///
1355/// These option contracts belong to the API boundary because NAPI and future
1356/// Rust embedders construct the same [`ComplexityOptions`] type.
1357///
1358/// A relative coverage path resolves against `analysis.root` (the cwd when
1359/// unset), exactly as the engine resolves it at read time, so a
1360/// project-relative `health.coverage` validates against the project and not
1361/// against the embedder's working directory.
1362///
1363/// The options carry no record of which layer supplied `coverage`, so the
1364/// path error's `context` is always `health.coverage`, whichever of the tool
1365/// parameter, `FALLOW_COVERAGE`, or the config field won
1366/// ([`crate::coverage::resolve_coverage_inputs`] resolves that order). The
1367/// sibling root error names its layer because
1368/// [`crate::coverage::CoverageInputError`] carries it.
1369///
1370/// # Errors
1371///
1372/// Returns a structured programmatic error when a coverage path does not exist
1373/// or when `coverage_root` is not an absolute prefix from the coverage data.
1374pub fn validate_complexity_options(options: &ComplexityOptions) -> Result<(), ProgrammaticError> {
1375    if let Some(path) = &options.coverage {
1376        let resolved = fallow_engine::health::scoring::resolve_relative_to_root(
1377            path,
1378            options.analysis.root.as_deref(),
1379        );
1380        if !resolved.exists() {
1381            return Err(ProgrammaticError::new(
1382                format!("coverage path does not exist: {}", resolved.display()),
1383                2,
1384            )
1385            .with_code("FALLOW_INVALID_COVERAGE_PATH")
1386            .with_context("health.coverage"));
1387        }
1388    }
1389    if let Err(message) =
1390        fallow_engine::health::validate_coverage_root_absolute(options.coverage_root.as_deref())
1391    {
1392        return Err(ProgrammaticError::new(message, 2)
1393            .with_code("FALLOW_INVALID_COVERAGE_ROOT")
1394            .with_context("health.coverage_root"));
1395    }
1396
1397    Ok(())
1398}
1399
1400fn complexity_section_options(options: &ComplexityOptions) -> ComplexitySectionOptions {
1401    let ownership = options.ownership || options.ownership_emails.is_some();
1402    let requested_targets = options.targets || options.effort.is_some();
1403    ComplexitySectionOptions {
1404        complexity: options.complexity,
1405        file_scores: options.file_scores,
1406        coverage_gaps: options.coverage_gaps,
1407        hotspots: options.hotspots,
1408        ownership,
1409        targets: requested_targets,
1410        css: options.css,
1411        score: options.score,
1412    }
1413}
1414
1415fn is_health_score_only_output(options: &HealthSectionOptions, score: bool) -> bool {
1416    score
1417        && !options.complexity
1418        && !options.file_scores
1419        && !options.coverage_gaps
1420        && !options.hotspots
1421        && !options.targets
1422        && !options.trend
1423}
1424
1425const fn thresholds_to_engine(
1426    thresholds: ComplexityThresholdOverrides,
1427) -> fallow_engine::health::HealthThresholdOverrides {
1428    fallow_engine::health::HealthThresholdOverrides {
1429        max_cyclomatic: thresholds.max_cyclomatic,
1430        max_cognitive: thresholds.max_cognitive,
1431        max_crap: thresholds.max_crap,
1432    }
1433}
1434
1435const fn complexity_sort_to_engine(sort: ComplexitySort) -> fallow_engine::health::HealthSort {
1436    match sort {
1437        ComplexitySort::Severity => fallow_engine::health::HealthSort::Severity,
1438        ComplexitySort::Cyclomatic => fallow_engine::health::HealthSort::Cyclomatic,
1439        ComplexitySort::Cognitive => fallow_engine::health::HealthSort::Cognitive,
1440        ComplexitySort::Lines => fallow_engine::health::HealthSort::Lines,
1441    }
1442}
1443
1444const fn coverage_inputs_to_engine(
1445    coverage_inputs: ComplexityCoverageInputs<'_>,
1446) -> fallow_engine::health::HealthCoverageInputs<'_> {
1447    fallow_engine::health::HealthCoverageInputs {
1448        coverage: coverage_inputs.coverage,
1449        coverage_root: coverage_inputs.coverage_root,
1450        coverage_relocated: coverage_inputs.coverage_relocated,
1451    }
1452}
1453
1454const fn ownership_email_mode_to_config(mode: OwnershipEmailMode) -> EmailMode {
1455    match mode {
1456        OwnershipEmailMode::Raw => EmailMode::Raw,
1457        OwnershipEmailMode::Handle => EmailMode::Handle,
1458        OwnershipEmailMode::Anonymized => EmailMode::Anonymized,
1459        OwnershipEmailMode::Hash => EmailMode::Hash,
1460    }
1461}
1462
1463const fn target_effort_to_output(effort: TargetEffort) -> EffortEstimate {
1464    match effort {
1465        TargetEffort::Low => EffortEstimate::Low,
1466        TargetEffort::Medium => EffortEstimate::Medium,
1467        TargetEffort::High => EffortEstimate::High,
1468    }
1469}
1470
1471#[cfg(test)]
1472mod tests {
1473    use super::*;
1474
1475    #[test]
1476    fn duplication_defaults_match_cli_contract() {
1477        let options = DuplicationOptions::default();
1478        assert!(options.mode.is_none());
1479        assert!(options.min_tokens.is_none());
1480        assert!(options.min_lines.is_none());
1481        assert!(options.min_occurrences.is_none());
1482    }
1483
1484    #[test]
1485    fn programmatic_error_builder_keeps_optional_fields() {
1486        let error = ProgrammaticError::new("boom", 2)
1487            .with_code("FALLOW_TEST")
1488            .with_help("Try again")
1489            .with_context("analysis.root");
1490
1491        assert_eq!(error.message, "boom");
1492        assert_eq!(error.exit_code, 2);
1493        assert_eq!(error.code.as_deref(), Some("FALLOW_TEST"));
1494        assert_eq!(error.help.as_deref(), Some("Try again"));
1495        assert_eq!(error.context.as_deref(), Some("analysis.root"));
1496    }
1497
1498    #[test]
1499    fn dead_code_filters_accept_shared_registry_selectors() {
1500        for (selector, _) in fallow_types::issue_meta::MCP_ISSUE_TYPE_FLAGS.iter() {
1501            let mut filters = DeadCodeFilters::default();
1502            assert!(
1503                filters.enable_registry_selector(selector),
1504                "{selector} should be accepted"
1505            );
1506        }
1507
1508        let mut filters = DeadCodeFilters::default();
1509        assert!(filters.enable_registry_selector("unused-files"));
1510        assert!(filters.unused_files);
1511        assert!(filters.enable_registry_selector("boundary-violations"));
1512        assert!(filters.boundary_violations);
1513        assert!(!filters.enable_registry_selector("not-a-real-selector"));
1514    }
1515
1516    #[test]
1517    fn default_complexity_options_match_programmatic_health_defaults() {
1518        let derived = derive_complexity_options(&ComplexityOptions::default());
1519
1520        assert!(!derived.any_section);
1521        assert!(derived.complexity);
1522        assert!(derived.file_scores);
1523        assert!(!derived.coverage_gaps);
1524        assert!(derived.hotspots);
1525        assert!(!derived.ownership);
1526        assert!(derived.targets);
1527        assert!(derived.force_full);
1528        assert!(!derived.score_only_output);
1529        assert!(derived.score);
1530    }
1531
1532    #[test]
1533    fn score_only_complexity_options_request_score_only_output() {
1534        let derived = derive_complexity_options(&ComplexityOptions {
1535            score: true,
1536            ..ComplexityOptions::default()
1537        });
1538
1539        assert!(derived.any_section);
1540        assert!(!derived.complexity);
1541        assert!(derived.file_scores);
1542        assert!(!derived.hotspots);
1543        assert!(!derived.targets);
1544        assert!(derived.force_full);
1545        assert!(derived.score_only_output);
1546        assert!(derived.score);
1547    }
1548
1549    #[test]
1550    fn ownership_implies_hotspots_when_requested() {
1551        let derived = derive_complexity_options(&ComplexityOptions {
1552            ownership: true,
1553            ..ComplexityOptions::default()
1554        });
1555
1556        assert!(derived.any_section);
1557        assert!(derived.hotspots);
1558        assert!(derived.ownership);
1559        assert!(!derived.targets);
1560    }
1561
1562    #[test]
1563    fn complexity_run_options_normalize_public_api_options() {
1564        let options = ComplexityOptions {
1565            max_cyclomatic: Some(42),
1566            max_cognitive: Some(21),
1567            max_crap: Some(18.5),
1568            top: Some(7),
1569            sort: ComplexitySort::Severity,
1570            complexity_breakdown: true,
1571            ownership_emails: Some(OwnershipEmailMode::Hash),
1572            effort: Some(TargetEffort::High),
1573            coverage: Some(PathBuf::from("coverage/coverage-final.json")),
1574            coverage_root: Some(PathBuf::from("/ci/workspace")),
1575            since: Some("30d".to_string()),
1576            min_commits: Some(4),
1577            ..ComplexityOptions::default()
1578        };
1579
1580        let run = derive_complexity_run_options(&options);
1581
1582        assert_eq!(run.thresholds.max_cyclomatic, Some(42));
1583        assert_eq!(run.thresholds.max_cognitive, Some(21));
1584        assert_eq!(run.thresholds.max_crap, Some(18.5));
1585        assert_eq!(run.top, Some(7));
1586        assert!(matches!(run.sort, ComplexitySort::Severity));
1587        assert!(run.complexity_breakdown);
1588        assert!(run.sections.hotspots);
1589        assert!(run.sections.ownership);
1590        assert!(run.sections.targets);
1591        assert!(matches!(
1592            run.ownership_emails,
1593            Some(OwnershipEmailMode::Hash)
1594        ));
1595        assert!(matches!(run.effort, Some(TargetEffort::High)));
1596        assert_eq!(run.since, Some("30d"));
1597        assert_eq!(run.min_commits, Some(4));
1598        assert_eq!(run.coverage_inputs.coverage, options.coverage.as_deref());
1599        assert_eq!(
1600            run.coverage_inputs.coverage_root,
1601            options.coverage_root.as_deref()
1602        );
1603    }
1604
1605    #[test]
1606    fn complexity_options_validation_accepts_existing_coverage_path_and_absolute_root() {
1607        let dir = tempfile::tempdir().expect("tempdir");
1608        let coverage = dir.path().join("coverage-final.json");
1609        std::fs::write(&coverage, "{}").expect("coverage fixture");
1610
1611        let result = validate_complexity_options(&ComplexityOptions {
1612            coverage: Some(coverage),
1613            coverage_root: Some(PathBuf::from("/ci/workspace")),
1614            ..ComplexityOptions::default()
1615        });
1616
1617        assert!(result.is_ok());
1618    }
1619
1620    #[test]
1621    fn complexity_options_validation_keeps_missing_coverage_error_contract() {
1622        let err = validate_complexity_options(&ComplexityOptions {
1623            coverage: Some(PathBuf::from("/missing/coverage-final.json")),
1624            ..ComplexityOptions::default()
1625        })
1626        .expect_err("missing coverage path should fail");
1627
1628        assert_eq!(err.exit_code, 2);
1629        assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_PATH"));
1630        assert_eq!(err.context.as_deref(), Some("health.coverage"));
1631    }
1632
1633    #[test]
1634    fn complexity_options_validation_keeps_relative_coverage_root_error_contract() {
1635        let err = validate_complexity_options(&ComplexityOptions {
1636            coverage_root: Some(PathBuf::from("coverage")),
1637            ..ComplexityOptions::default()
1638        })
1639        .expect_err("relative coverage root should fail");
1640
1641        assert_eq!(err.exit_code, 2);
1642        assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_ROOT"));
1643        assert_eq!(err.context.as_deref(), Some("health.coverage_root"));
1644    }
1645
1646    /// #2368: a project-relative coverage path (the documented form of
1647    /// `health.coverage`) must be checked under the analysis root, not under
1648    /// the embedder's working directory.
1649    #[test]
1650    fn complexity_options_validation_resolves_relative_coverage_against_root() {
1651        let dir = tempfile::tempdir().expect("tempdir");
1652        std::fs::create_dir_all(dir.path().join("artifacts")).expect("artifacts dir");
1653        std::fs::write(dir.path().join("artifacts/coverage-final.json"), "{}")
1654            .expect("coverage fixture");
1655        let relative = PathBuf::from("artifacts/coverage-final.json");
1656        assert!(
1657            !relative.exists(),
1658            "the fixture must not also exist under the test cwd"
1659        );
1660
1661        let result = validate_complexity_options(&ComplexityOptions {
1662            analysis: AnalysisOptions {
1663                root: Some(dir.path().to_path_buf()),
1664                ..AnalysisOptions::default()
1665            },
1666            coverage: Some(relative.clone()),
1667            ..ComplexityOptions::default()
1668        });
1669        assert!(result.is_ok(), "{result:?}");
1670
1671        let err = validate_complexity_options(&ComplexityOptions {
1672            analysis: AnalysisOptions {
1673                root: Some(dir.path().join("elsewhere")),
1674                ..AnalysisOptions::default()
1675            },
1676            coverage: Some(relative),
1677            ..ComplexityOptions::default()
1678        })
1679        .expect_err("the path does not exist under the other root");
1680        assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_PATH"));
1681        assert!(
1682            err.message.contains("elsewhere"),
1683            "the message names the resolved path: {}",
1684            err.message
1685        );
1686    }
1687
1688    #[test]
1689    fn default_health_sections_match_full_health_output() {
1690        let derived = derive_health_sections(&HealthSectionOptions {
1691            output: fallow_types::output_format::OutputFormat::Human,
1692            complexity: false,
1693            file_scores: false,
1694            coverage_gaps: false,
1695            hotspots: false,
1696            targets: false,
1697            css: false,
1698            score: false,
1699            score_gate: false,
1700            snapshot_requested: false,
1701            trend: false,
1702        });
1703
1704        assert!(!derived.any_section);
1705        assert!(derived.complexity);
1706        assert!(derived.file_scores);
1707        assert!(!derived.coverage_gaps);
1708        assert!(derived.hotspots);
1709        assert!(derived.targets);
1710        assert!(derived.score);
1711        assert!(derived.force_full);
1712        assert!(!derived.score_only_output);
1713    }
1714
1715    #[test]
1716    fn health_score_gate_requests_score_only_output() {
1717        let derived = derive_health_sections(&HealthSectionOptions {
1718            output: fallow_types::output_format::OutputFormat::Human,
1719            complexity: false,
1720            file_scores: false,
1721            coverage_gaps: false,
1722            hotspots: false,
1723            targets: false,
1724            css: false,
1725            score: false,
1726            score_gate: true,
1727            snapshot_requested: false,
1728            trend: false,
1729        });
1730
1731        assert!(derived.any_section);
1732        assert!(!derived.complexity);
1733        assert!(derived.file_scores);
1734        assert!(!derived.hotspots);
1735        assert!(!derived.targets);
1736        assert!(derived.score);
1737        assert!(derived.force_full);
1738        assert!(derived.score_only_output);
1739    }
1740
1741    #[test]
1742    fn health_snapshot_keeps_full_hidden_inputs_without_section_request() {
1743        let derived = derive_health_sections(&HealthSectionOptions {
1744            output: fallow_types::output_format::OutputFormat::Human,
1745            complexity: false,
1746            file_scores: false,
1747            coverage_gaps: false,
1748            hotspots: false,
1749            targets: false,
1750            css: true,
1751            score: false,
1752            score_gate: false,
1753            snapshot_requested: true,
1754            trend: false,
1755        });
1756
1757        assert!(!derived.any_section);
1758        assert!(derived.css);
1759        assert!(derived.file_scores);
1760        assert!(derived.hotspots);
1761        assert!(derived.score);
1762        assert!(derived.force_full);
1763    }
1764}