Skip to main content

fallow_api/
lib.rs

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