Skip to main content

fallow_api/
lib.rs

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