1#![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;
28pub mod audit_keys;
32pub mod audit_output;
33pub mod audit_run;
34pub mod combined_output;
35pub 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;
53pub 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;
65pub mod schemas;
68pub mod security_output;
69pub 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};
119pub use fallow_engine::change_scope::{
122 ChangeScope, ChangeScopeOwner, ChangeScopeRequest, first_package_baselines,
123 package_baseline_statuses,
124};
125pub use fallow_engine::package_baselines::{PackageBaselineError, PackageChangeScope};
127pub use fallow_engine::warm_parse;
133pub 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
225pub 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#[derive(Debug, Clone, Serialize)]
247pub struct ProgrammaticError {
248 pub message: String,
250 pub exit_code: u8,
252 pub code: Option<String>,
254 pub help: Option<String>,
256 pub context: Option<String>,
258}
259
260impl ProgrammaticError {
261 #[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 #[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 #[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 #[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#[derive(Debug, Clone, Default)]
308pub struct AnalysisOptions {
309 pub root: Option<PathBuf>,
312 pub config_path: Option<PathBuf>,
314 pub allow_remote_extends: bool,
316 pub no_cache: bool,
318 pub threads: Option<usize>,
320 pub diff_file: Option<PathBuf>,
325 pub ambient_diff_file: Option<PathBuf>,
333 pub production: bool,
336 pub production_override: Option<bool>,
339 pub changed_since: Option<String>,
344 pub ambient_changed_since: Option<String>,
352 pub no_package_baselines: bool,
356 pub workspace: Option<Vec<String>>,
358 pub changed_workspaces: Option<String>,
360 pub explain: bool,
362 pub type_aware: TypeAwareOptions,
365 pub cancellation: Option<Arc<AtomicBool>>,
395}
396
397#[derive(Debug, Clone, Default, PartialEq, Eq)]
399pub struct TypeAwareOptions {
400 pub enabled: bool,
402 pub projects: Vec<PathBuf>,
405 pub require: fallow_config::TypeAwareRequire,
407}
408
409#[derive(Debug, Clone, Default)]
414pub struct DeadCodeFilters {
415 pub unused_files: bool,
417 pub unused_exports: bool,
419 pub unused_deps: bool,
421 pub unused_types: bool,
423 pub private_type_leaks: bool,
425 pub deprecated_exports_in_use: bool,
427 pub unused_enum_members: bool,
429 pub unused_class_members: bool,
431 pub unused_store_members: bool,
433 pub unprovided_injects: bool,
435 pub unrendered_components: bool,
437 pub unused_component_props: bool,
439 pub absent_component_props: bool,
441 pub unused_component_emits: bool,
443 pub unused_component_inputs: bool,
445 pub unused_component_outputs: bool,
447 pub unused_svelte_events: bool,
449 pub unused_server_actions: bool,
451 pub unused_load_data_keys: bool,
453 pub unresolved_imports: bool,
455 pub unlisted_deps: bool,
457 pub duplicate_exports: bool,
459 pub circular_deps: bool,
461 pub re_export_cycles: bool,
463 pub package_cycles: bool,
465 pub boundary_violations: bool,
467 pub policy_violations: bool,
469 pub stale_suppressions: bool,
471 pub unused_catalog_entries: bool,
473 pub empty_catalog_groups: bool,
475 pub unresolved_catalog_references: bool,
477 pub unused_dependency_overrides: bool,
479 pub misconfigured_dependency_overrides: bool,
481}
482
483impl DeadCodeFilters {
484 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 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#[derive(Debug, Clone, Default)]
587pub struct DeadCodeOptions {
588 pub analysis: AnalysisOptions,
590 pub filters: DeadCodeFilters,
592 pub files: Vec<PathBuf>,
594 pub include_entry_exports: bool,
596 pub show_cascade: bool,
598 pub finding_ids: Vec<String>,
603}
604
605#[derive(Debug, Clone, Default)]
611pub struct ArchitectureOptions {
612 pub analysis: AnalysisOptions,
614 pub files: Vec<PathBuf>,
616 pub cycles: bool,
619 pub boundaries: bool,
621 pub policy: bool,
623}
624
625impl ArchitectureOptions {
626 #[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 #[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#[derive(Debug, Clone, Default)]
656pub struct AuditOptions {
657 pub analysis: AnalysisOptions,
659 pub base: Option<String>,
662 pub production: bool,
664 pub production_dead_code: Option<bool>,
666 pub production_health: Option<bool>,
668 pub production_dupes: Option<bool>,
671 pub css: Option<bool>,
673 pub css_deep: Option<bool>,
675 pub gate: fallow_config::AuditGate,
677 pub max_crap: Option<f64>,
679 pub coverage: Option<PathBuf>,
681 pub coverage_root: Option<PathBuf>,
683 pub include_entry_exports: bool,
685 pub show_cascade: bool,
687 pub runtime_coverage: Option<PathBuf>,
689 pub min_invocations_hot: u64,
691}
692
693#[derive(Debug, Clone)]
695pub struct CombinedOptions {
696 pub analysis: AnalysisOptions,
698 pub dead_code: bool,
700 pub duplication: bool,
702 pub health: bool,
704 pub include_entry_exports: bool,
706 pub show_cascade: bool,
708 pub duplication_options: DuplicationOptions,
710 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#[derive(Debug, Clone, Default)]
731pub struct DecisionSurfaceOptions {
732 pub analysis: AnalysisOptions,
734 pub base: Option<String>,
737 pub max_decisions: Option<usize>,
739}
740
741#[derive(Debug, Clone, Default)]
743pub struct FeatureFlagsOptions {
744 pub analysis: AnalysisOptions,
746 pub top: Option<usize>,
749 pub retirement: Option<FeatureFlagsRetirementOptions>,
751}
752
753#[derive(Debug, Clone, Default)]
756pub struct FeatureFlagsRetirementOptions {
757 pub flag_age: fallow_types::flag_retirement::FlagAgeMode,
759 pub flag_state: Option<std::path::PathBuf>,
761 pub reasons: Vec<fallow_types::flag_retirement::RetirementReason>,
763 pub sort: fallow_engine::flag_retirement::RetirementSort,
765 pub min_age_days: Option<u64>,
767 pub max_flag_age: Option<u64>,
769}
770
771#[derive(Debug, Clone, Copy, Default)]
773pub enum DuplicationMode {
774 Strict,
777 #[default]
779 Mild,
780 Weak,
782 Semantic,
785}
786
787#[derive(Debug, Clone, Default)]
789pub struct DuplicationOptions {
790 pub analysis: AnalysisOptions,
792 pub mode: Option<DuplicationMode>,
794 pub near: Option<bool>,
797 pub min_tokens: Option<usize>,
799 pub min_lines: Option<usize>,
801 pub min_occurrences: Option<usize>,
804 pub threshold: Option<f64>,
807 pub skip_local: Option<bool>,
810 pub ignore_symlinks: Option<bool>,
813 pub cross_language: Option<bool>,
815 pub ignore_imports: Option<bool>,
818 pub top: Option<usize>,
820 pub include_fragments: Option<bool>,
824}
825
826#[derive(Debug, Clone, Default)]
828pub struct SimilarCodeOptions {
829 pub analysis: AnalysisOptions,
831 pub threshold: Option<f64>,
833 pub min_lines: Option<usize>,
836 pub top: Option<usize>,
838 pub files: Vec<PathBuf>,
842 #[doc(hidden)]
847 pub adapter_provider_path: Option<PathBuf>,
848}
849
850#[derive(Debug, Clone)]
852pub struct SimilarCodeInspectOptions {
853 pub analysis: AnalysisOptions,
856 pub snapshot: fallow_output::SimilarCodeCandidateSnapshot,
858}
859
860#[derive(Debug, Clone, Default)]
862pub struct TraceExportOptions {
863 pub analysis: AnalysisOptions,
865 pub file: String,
867 pub export_name: String,
869}
870
871#[derive(Debug, Clone, Default)]
873pub struct TraceFileOptions {
874 pub analysis: AnalysisOptions,
876 pub file: String,
878}
879
880#[derive(Debug, Clone, Default)]
882pub struct TraceImportPathOptions {
883 pub analysis: AnalysisOptions,
885 pub from: String,
887 pub to: String,
889 pub eager_only: bool,
892}
893
894#[derive(Debug, Clone, Default)]
896pub struct TraceErrorOptions {
897 pub analysis: AnalysisOptions,
899 pub trace: String,
901 pub source: String,
903}
904
905#[derive(Debug, Clone, Default)]
907pub struct TraceDependencyOptions {
908 pub analysis: AnalysisOptions,
910 pub package_name: String,
912 pub usage: Option<DependencyUsageQuery>,
915}
916
917#[derive(Debug, Clone, PartialEq, Eq)]
919pub enum TraceCloneTarget {
920 Location {
922 file: String,
924 line: usize,
926 },
927 Fingerprint(String),
929}
930
931#[derive(Debug, Clone)]
933pub struct TraceCloneOptions {
934 pub duplication: DuplicationOptions,
936 pub target: TraceCloneTarget,
938}
939
940#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
942pub enum ComplexitySort {
943 #[default]
945 Cyclomatic,
946 Cognitive,
948 Lines,
950 Severity,
952}
953
954#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
956pub enum OwnershipEmailMode {
957 Raw,
959 #[default]
961 Handle,
962 Anonymized,
964 Hash,
966}
967
968#[derive(Debug, Clone, Copy, PartialEq, Eq)]
970pub enum TargetEffort {
971 Low,
973 Medium,
975 High,
977}
978
979#[derive(Debug, Clone, Default)]
981pub struct ComplexityOptions {
982 pub analysis: AnalysisOptions,
984 pub max_cyclomatic: Option<u16>,
986 pub max_cognitive: Option<u16>,
988 pub max_crap: Option<f64>,
990 pub top: Option<usize>,
992 pub sort: ComplexitySort,
994 pub complexity_breakdown: bool,
996 pub complexity: bool,
998 pub file_scores: bool,
1000 pub coverage_gaps: bool,
1002 pub hotspots: bool,
1004 pub ownership: bool,
1006 pub ownership_emails: Option<OwnershipEmailMode>,
1008 pub targets: bool,
1010 pub css: bool,
1012 pub css_deep: bool,
1014 pub effort: Option<TargetEffort>,
1017 pub score: bool,
1019 pub since: Option<String>,
1021 pub min_commits: Option<u32>,
1023 pub coverage: Option<PathBuf>,
1025 pub coverage_root: Option<PathBuf>,
1027 pub coverage_relocated: bool,
1032}
1033
1034#[derive(Debug, Clone, Copy, Default, PartialEq)]
1036pub struct ComplexityThresholdOverrides {
1037 pub max_cyclomatic: Option<u16>,
1039 pub max_cognitive: Option<u16>,
1041 pub max_crap: Option<f64>,
1043}
1044
1045#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1047pub struct ComplexityCoverageInputs<'a> {
1048 pub coverage: Option<&'a Path>,
1050 pub coverage_root: Option<&'a Path>,
1052 pub coverage_relocated: bool,
1055}
1056
1057#[derive(Debug, Clone)]
1059pub struct HealthSectionOptions {
1060 pub output: fallow_types::output_format::OutputFormat,
1062 pub complexity: bool,
1064 pub file_scores: bool,
1066 pub coverage_gaps: bool,
1068 pub hotspots: bool,
1070 pub targets: bool,
1072 pub css: bool,
1074 pub score: bool,
1076 pub score_gate: bool,
1078 pub snapshot_requested: bool,
1080 pub trend: bool,
1082}
1083
1084#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1086pub struct DerivedHealthSections {
1087 pub any_section: bool,
1089 pub complexity: bool,
1091 pub file_scores: bool,
1093 pub coverage_gaps: bool,
1095 pub hotspots: bool,
1097 pub targets: bool,
1099 pub css: bool,
1101 pub score: bool,
1103 pub force_full: bool,
1105 pub score_only_output: bool,
1107}
1108
1109#[derive(Debug, Clone)]
1111pub struct ComplexitySectionOptions {
1112 pub complexity: bool,
1114 pub file_scores: bool,
1116 pub coverage_gaps: bool,
1118 pub hotspots: bool,
1120 pub ownership: bool,
1122 pub targets: bool,
1124 pub css: bool,
1126 pub score: bool,
1128}
1129
1130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1132pub struct DerivedComplexityOptions {
1133 pub any_section: bool,
1135 pub complexity: bool,
1137 pub file_scores: bool,
1139 pub coverage_gaps: bool,
1141 pub hotspots: bool,
1143 pub ownership: bool,
1145 pub targets: bool,
1147 pub force_full: bool,
1149 pub score_only_output: bool,
1151 pub score: bool,
1153}
1154
1155#[derive(Debug, Clone, PartialEq)]
1157pub struct ComplexityRunOptions<'a> {
1158 pub thresholds: ComplexityThresholdOverrides,
1160 pub top: Option<usize>,
1162 pub sort: ComplexitySort,
1164 pub complexity_breakdown: bool,
1166 pub sections: DerivedComplexityOptions,
1168 pub ownership_emails: Option<OwnershipEmailMode>,
1170 pub effort: Option<TargetEffort>,
1172 pub css: bool,
1174 pub css_deep: bool,
1176 pub since: Option<&'a str>,
1178 pub min_commits: Option<u32>,
1180 pub coverage_inputs: ComplexityCoverageInputs<'a>,
1182}
1183
1184#[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#[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#[must_use]
1265pub fn derive_complexity_options(options: &ComplexityOptions) -> DerivedComplexityOptions {
1266 derive_complexity_sections(&complexity_section_options(options))
1267}
1268
1269#[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
1296pub 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 #[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}