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