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