1#![expect(
2 clippy::print_stdout,
3 clippy::print_stderr,
4 reason = "CLI binary produces intentional terminal output"
5)]
6#![cfg_attr(
7 test,
8 allow(
9 clippy::unwrap_used,
10 clippy::expect_used,
11 reason = "tests use unwrap and expect to keep fixture setup concise"
12 )
13)]
14
15use std::io::IsTerminal as _;
16use std::path::{Path, PathBuf};
17use std::process::ExitCode;
18
19use clap::{Parser, Subcommand};
20
21mod api;
22#[cfg(test)]
23mod architecture_boundaries;
24mod audit;
25mod audit_brief;
26mod audit_cache_prune;
27mod audit_decision_surface;
28mod audit_focus;
29mod audit_walkthrough;
30mod base_worktree;
31pub use base_worktree::canonical_root_hash;
35mod walkthrough_state;
36use fallow_engine::baseline;
37mod agent_install;
38mod baseline_gate;
39mod baseline_growth;
40mod cache_notice;
41mod check;
42mod ci;
43mod ci_template;
44mod cli_agent;
45mod cli_format;
46mod cli_hooks;
47mod cli_impact;
48mod cli_production;
49mod cli_report;
50mod cli_startup;
51pub use fallow_engine::codeowners;
52mod combined;
53mod config;
54mod coverage;
55mod discovery_note;
56mod doctor;
57mod dupes;
58mod exit_codes;
59pub mod explain;
60mod fix;
61mod flags;
62mod flags_retirement_formats;
63mod gates;
64mod guard;
65mod health;
66mod impact;
67mod init;
68mod inspect;
69mod json_style;
70mod license;
71mod list;
72mod migrate;
73mod onboarding;
74#[cfg(test)]
75mod output_envelope;
76mod output_runtime;
77mod path_util;
78mod plugin_check;
79mod process_clock;
80mod rayon_pool;
81mod regression;
82pub mod report;
83mod requests;
84mod rule_pack;
85mod runtime_support;
86mod schema;
87mod scope_path;
88mod security;
89mod security_help;
90mod selector;
91mod setup_hooks;
92mod signal;
93mod similar_code_cli;
94mod similar_code_help;
95mod suppressions;
96mod task_matrix;
97mod telemetry;
98mod trace_chain;
99mod trace_error;
100mod trace_path;
101mod type_aware_degrade;
102mod update_check;
103use fallow_engine::validate;
104use fallow_engine::vital_signs;
105mod cli_telemetry;
106mod viz;
107mod watch;
108mod write_scope;
109
110use check::{CheckOptions, IssueFilters, TraceOptions};
111pub(crate) mod error;
113use cli_agent::{AgentCli, run_agent_command};
114#[cfg(test)]
115use cli_format::parse_format_arg;
116use cli_format::{Format, FormatConfig};
117use cli_hooks::{HooksCli, run_hooks_command};
118use cli_impact::{ImpactCli, ImpactCrossRepoOpts, ImpactSortCli, dispatch_impact};
119use cli_production::{ProductionModes, resolve_production_modes};
120#[cfg(test)]
121use cli_startup::build_tracing_filter;
122use cli_startup::{
123 bare_combined_baseline_subcommand_error_message, bare_coverage_subcommand_error_message,
124 cli_bare_combined_baseline_flag, cli_has_bare_coverage_input, parse_cli_args,
125 reject_global_baseline_flags, run_pre_dispatch_checks, setup_tracing, validate_inputs,
126};
127#[cfg(test)]
128use cli_telemetry::TelemetryRun;
129#[cfg(test)]
130use cli_telemetry::{fallback_failure_reason_for, telemetry_workflow_for_command};
131use cli_telemetry::{record_run_epilogue, start_telemetry_run};
132use dupes::{DupesMode, DupesOptions};
133use error::emit_error;
134use health::{HealthOptions, SortBy};
135use list::ListOptions;
136pub(crate) use runtime_support::{AnalysisKind, GroupBy};
137pub(crate) use runtime_support::{
138 ConfigLoadOptions, LoadConfigArgs, build_ownership_resolver, load_config,
139 load_config_for_analysis,
140};
141#[cfg(test)]
142use security_help::{SECURITY_UNSUPPORTED_GLOBAL_LONGS, SecurityHelpTarget};
143use security_help::{render_security_help, security_help_target};
144use similar_code_help::{render_similar_code_help, similar_code_help_target};
145
146const DEFAULT_MIN_INVOCATIONS_HOT: u64 = 100;
147
148const TOP_LEVEL_HELP_TEMPLATE: &str =
149 "{about-with-newline}\n{usage-heading} {usage}{after-help}\n\nOptions:\n{options}";
150
151macro_rules! top_level_task_cheat_sheet {
154 () => {
155 "\
156When the agent is about to...
157 delete an \"unused\" export or file fallow dead-code --trace <file>:<export>
158 prove exact TypeScript symbol consumers fallow dead-code --type-aware --symbol-impact <file>:<export-or-class.method>
159 find how one module reaches another fallow trace --path <from> <to>
160 delete an \"unused\" dependency fallow dead-code --trace-dependency <name>
161 commit or open a PR fallow audit --base <ref>
162 read a diff before approving it fallow review --base <ref> --brief
163 prioritize refactoring fallow health --hotspots --targets
164 ask who owns code fallow health --ownership
165 check untested-but-reachable code fallow health --coverage-gaps
166 consolidate duplication fallow dupes --trace dup:<fingerprint>
167 find feature flags fallow flags
168 check architecture rules before editing fallow guard <files>
169 surface security candidates fallow security
170 inspect a target before editing fallow inspect --file <path>
171 understand a finding fallow explain <issue-type>
172 scope a monorepo --workspace <glob> / --changed-workspaces <ref>"
173 };
174}
175
176macro_rules! top_level_core_command_groups {
177 () => {
178 "\
179Analysis:
180 dead-code Analyze unused code, dependency hygiene, and architecture cycles
181 dupes Find copy-paste and structural code duplication
182 health Analyze complexity, maintainability, hotspots, and coverage gaps
183 flags Detect feature flag usage patterns
184 security Surface local security candidates for agent verification (opt-in)
185 similar-code Find semantic implementation overlap for verification (opt-in, local)
186 audit Review changed files for dead code, complexity, duplication, and styling
187
188Workflow:
189 watch Re-run analysis as files change
190 fix Auto-fix safe unused-code findings"
191 };
192}
193
194macro_rules! top_level_extended_command_groups {
195 () => {
196 "\
197Project inspection:
198 list List discovered files, entry points, plugins, boundaries, workspaces, and entry weight
199 inspect Inspect one file or exported symbol as a bundled evidence query
200 trace Trace a symbol's call chain (best-effort, syntactic)
201 trace-error Resolve a runtime stack trace's frames to project definitions
202 guard Show which architecture rules apply to files before editing
203 decision-surface Surface the structural decisions a change embeds (advisory)
204 workspaces Show monorepo workspace discovery diagnostics
205 explain Explain one issue type without running analysis
206 suppressions List active fallow-ignore suppression markers
207 impact Show what fallow has done for you (opt-in, local-only)
208 viz Generate an interactive HTML map of the codebase
209
210Setup and configuration:
211 doctor Diagnose project readiness without changing anything
212 init Create a fallow config, optionally with a Git hook
213 agent Wire fallow into Claude Code, Codex, or Cursor in one pass
214 audit-cache Maintain reusable audit base-snapshot caches
215 recommend Recommend a project-tailored config for an agent to author
216 migrate Migrate knip, jscpd, or stylelint config to fallow
217 config Show the resolved config and loaded config file
218 config-schema Print the fallow config JSON Schema
219 plugin-schema Print the external plugin JSON Schema
220 plugin-check Dry-run external plugins and report what they seed
221 rule-pack Manage declarative rule packs (policy-as-code)
222 rule-pack-schema Print the rule pack JSON Schema
223 type-aware Inspect the optional TypeScript semantic companion
224
225Automation and CI:
226 ci Build PR/MR feedback envelopes
227 ci-template Print or vendor CI integration templates
228 report Re-render saved JSON as GitHub or CodeClimate output
229 hooks Install or remove fallow-managed Git and agent hooks
230 setup-hooks Deprecated: use `agent install` or `hooks install --target agent`
231
232Runtime coverage:
233 coverage Set up or analyze runtime coverage data
234 license Manage the paid-feature license
235 telemetry Manage opt-in product telemetry
236
237Reference:
238 schema Dump the CLI interface as machine-readable JSON
239 help Print this message or the help of a command"
240 };
241}
242
243const TOP_LEVEL_AFTER_HELP: &str = concat!(
244 top_level_task_cheat_sheet!(),
245 "\n\n",
246 top_level_core_command_groups!(),
247 "\n\nRun fallow --help for the complete command list."
248);
249
250const TOP_LEVEL_AFTER_LONG_HELP: &str = concat!(
251 top_level_task_cheat_sheet!(),
252 "\n\n",
253 top_level_core_command_groups!(),
254 "\n\n",
255 top_level_extended_command_groups!(),
256 "\n\n",
257 "When no command is given, fallow runs dead-code + dupes + health together.\n",
258 "Use --only/--skip to select specific analyses."
259);
260
261#[derive(Parser)]
262#[command(
263 name = "fallow",
264 about = "Codebase analyzer for TypeScript/JavaScript: unused code, circular dependencies, code duplication, complexity hotspots, and architecture boundary violations",
265 version,
266 disable_version_flag = true,
267 help_template = TOP_LEVEL_HELP_TEMPLATE,
268 after_help = TOP_LEVEL_AFTER_HELP,
269 after_long_help = TOP_LEVEL_AFTER_LONG_HELP
270)]
271struct Cli {
272 #[command(subcommand)]
273 command: Option<Command>,
274
275 #[arg(value_name = "PATH")]
278 path: Option<PathBuf>,
279
280 #[arg(
284 short = 'v',
285 visible_short_alias = 'V',
286 long = "version",
287 action = clap::ArgAction::Version
288 )]
289 version: Option<bool>,
290
291 #[arg(short, long, global = true)]
293 root: Option<PathBuf>,
294
295 #[arg(short, long, global = true)]
297 config: Option<PathBuf>,
298
299 #[arg(hide_short_help = true, long, global = true)]
301 allow_remote_extends: bool,
302
303 #[arg(
305 short,
306 long,
307 visible_alias = "output",
308 global = true,
309 default_value = "human"
310 )]
311 format: Format,
312
313 #[arg(hide_short_help = true, long, global = true)]
315 pretty: bool,
316
317 #[arg(short, long, global = true)]
319 quiet: bool,
320
321 #[arg(hide_short_help = true, long, global = true)]
323 no_cache: bool,
324
325 #[arg(hide_short_help = true, long, global = true)]
327 threads: Option<usize>,
328
329 #[arg(long, visible_alias = "base", global = true)]
334 changed_since: Option<String>,
335
336 #[arg(hide_short_help = true, long, global = true)]
342 no_package_baselines: bool,
343
344 #[arg(
349 hide_short_help = true,
350 long = "diff-file",
351 value_name = "PATH",
352 global = true
353 )]
354 diff_file: Option<PathBuf>,
355
356 #[arg(hide_short_help = true, long = "diff-stdin", global = true)]
359 diff_stdin: bool,
360
361 #[arg(
368 hide_short_help = true,
369 long = "churn-file",
370 value_name = "PATH",
371 global = true
372 )]
373 churn_file: Option<PathBuf>,
374
375 #[arg(
382 hide_short_help = true,
383 long = "max-file-size",
384 value_name = "MB",
385 global = true
386 )]
387 max_file_size: Option<u32>,
388
389 #[arg(hide_short_help = true, long, global = true)]
392 baseline: Option<PathBuf>,
393
394 #[arg(
409 hide_short_help = true,
410 long = "baseline-mode",
411 value_enum,
412 global = true
413 )]
414 baseline_mode: Option<BaselineModeArg>,
415
416 #[arg(long, global = true, value_name = "RUN_ID", hide = true)]
422 parent_run: Option<String>,
423
424 #[arg(hide_short_help = true, long, global = true)]
428 save_baseline: Option<PathBuf>,
429
430 #[arg(long, global = true)]
433 production: bool,
434
435 #[arg(
439 hide_short_help = true,
440 long = "no-production",
441 global = true,
442 conflicts_with = "production"
443 )]
444 no_production: bool,
445
446 #[arg(hide_short_help = true, long = "production-dead-code")]
448 production_dead_code: bool,
449
450 #[arg(hide_short_help = true, long = "production-health")]
452 production_health: bool,
453
454 #[arg(hide_short_help = true, long = "production-dupes")]
456 production_dupes: bool,
457
458 #[arg(short, long, global = true, value_delimiter = ',')]
462 workspace: Option<Vec<String>>,
463
464 #[arg(long, global = true, value_name = "REF")]
467 changed_workspaces: Option<String>,
468
469 #[arg(hide_short_help = true, long, global = true)]
471 group_by: Option<GroupBy>,
472
473 #[arg(hide_short_help = true, long, global = true)]
475 performance: bool,
476
477 #[arg(hide_short_help = true, long, global = true)]
479 explain: bool,
480
481 #[arg(hide_short_help = true, long, global = true)]
484 explain_skipped: bool,
485
486 #[arg(hide_short_help = true, long, global = true)]
488 summary: bool,
489
490 #[arg(long, global = true)]
492 ci: bool,
493
494 #[arg(hide_short_help = true, long, global = true)]
496 fail_on_issues: bool,
497
498 #[arg(hide_short_help = true, long, global = true, value_name = "PATH")]
503 sarif_file: Option<PathBuf>,
504
505 #[arg(short = 'o', long, global = true, value_name = "PATH")]
511 output_file: Option<PathBuf>,
512
513 #[arg(
523 hide_short_help = true,
524 long = "report-path-prefix",
525 visible_alias = "annotations-path-prefix",
526 global = true,
527 value_name = "PREFIX"
528 )]
529 report_path_prefix: Option<String>,
530
531 #[arg(hide_short_help = true, long, global = true)]
537 fail_on_regression: bool,
538
539 #[arg(hide_short_help = true, long, global = true)]
553 fail_on_stale_baseline: bool,
554
555 #[arg(hide_short_help = true, long, global = true)]
570 fail_on_baseline_growth: bool,
571
572 #[arg(hide_short_help = true, long, global = true, value_name = "REF")]
577 baseline_base: Option<String>,
578
579 #[arg(hide_short_help = true, long, global = true)]
589 fail_on_parse_error: bool,
590
591 #[arg(
597 hide_short_help = true,
598 long,
599 global = true,
600 value_name = "TOLERANCE",
601 default_value = "0"
602 )]
603 tolerance: String,
604
605 #[arg(hide_short_help = true, long, global = true, value_name = "PATH")]
607 regression_baseline: Option<PathBuf>,
608
609 #[expect(
614 clippy::option_option,
615 reason = "clap pattern: None=not passed, Some(None)=flag only (write to config), Some(Some(path))=write to file"
616 )]
617 #[arg(hide_short_help = true, long, global = true, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
618 save_regression_baseline: Option<Option<String>>,
619
620 #[arg(long, value_delimiter = ',')]
622 only: Vec<AnalysisKind>,
623
624 #[arg(long, value_delimiter = ',')]
626 skip: Vec<AnalysisKind>,
627
628 #[arg(hide_short_help = true, long = "dupes-mode", global = true)]
630 dupes_mode: Option<DupesMode>,
631
632 #[arg(hide_short_help = true, long = "dupes-near", global = true)]
634 dupes_near: bool,
635
636 #[arg(hide_short_help = true, long = "dupes-threshold", global = true)]
638 dupes_threshold: Option<f64>,
639
640 #[arg(hide_short_help = true, long = "dupes-min-tokens", global = true)]
642 dupes_min_tokens: Option<usize>,
643
644 #[arg(hide_short_help = true, long = "dupes-min-lines", global = true)]
646 dupes_min_lines: Option<usize>,
647
648 #[arg(hide_short_help = true, long = "dupes-min-occurrences", global = true, value_parser = parse_min_occurrences)]
650 dupes_min_occurrences: Option<usize>,
651
652 #[arg(hide_short_help = true, long = "dupes-skip-local", global = true)]
654 dupes_skip_local: bool,
655
656 #[arg(hide_short_help = true, long = "dupes-cross-language", global = true)]
658 dupes_cross_language: bool,
659
660 #[arg(hide_short_help = true, long = "dupes-ignore-imports", global = true)]
663 dupes_ignore_imports: bool,
664
665 #[arg(
668 hide_short_help = true,
669 long = "dupes-no-ignore-imports",
670 global = true,
671 conflicts_with = "dupes_ignore_imports"
672 )]
673 dupes_no_ignore_imports: bool,
674
675 #[arg(hide_short_help = true, long)]
677 score: bool,
678
679 #[arg(hide_short_help = true, long)]
681 trend: bool,
682
683 #[expect(
687 clippy::option_option,
688 reason = "clap pattern: None=not passed, Some(None)=default path, Some(Some(path))=custom path"
689 )]
690 #[arg(hide_short_help = true, long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
691 save_snapshot: Option<Option<String>>,
692
693 #[arg(hide_short_help = true, long, value_name = "PATH")]
696 coverage: Option<PathBuf>,
697
698 #[arg(hide_short_help = true, long = "coverage-root", value_name = "PATH")]
701 coverage_root: Option<PathBuf>,
702
703 #[arg(hide_short_help = true, long = "dupes-baseline", value_name = "PATH")]
706 dupes_baseline: Option<PathBuf>,
707
708 #[arg(hide_short_help = true, long = "health-baseline", value_name = "PATH")]
711 health_baseline: Option<PathBuf>,
712
713 #[arg(hide_short_help = true, long, global = true)]
715 include_entry_exports: bool,
716
717 #[arg(hide_short_help = true, long, global = true)]
720 type_aware: bool,
721
722 #[arg(
725 hide_short_help = true,
726 long,
727 global = true,
728 conflicts_with = "type_aware"
729 )]
730 no_type_aware: bool,
731
732 #[arg(hide_short_help = true, long, global = true, value_name = "PATH", action = clap::ArgAction::Append)]
734 type_aware_project: Vec<PathBuf>,
735
736 #[arg(hide_short_help = true, long, global = true, value_enum)]
738 type_aware_require: Option<TypeAwareRequireArg>,
739}
740
741impl Cli {
742 const fn type_aware_override(&self) -> Option<bool> {
746 if self.no_type_aware {
747 Some(false)
748 } else if self.type_aware {
749 Some(true)
750 } else {
751 None
752 }
753 }
754}
755
756#[derive(Clone, Copy, Subcommand)]
757enum TypeAwareCli {
758 Status,
760}
761
762#[derive(Subcommand)]
763enum Command {
764 #[command(name = "dead-code", alias = "check")]
766 Check {
767 #[arg(long)]
769 unused_files: bool,
770
771 #[arg(long)]
773 unused_exports: bool,
774
775 #[arg(long)]
777 unused_deps: bool,
778
779 #[arg(long)]
781 unused_types: bool,
782
783 #[arg(long)]
785 private_type_leaks: bool,
786
787 #[arg(long)]
789 deprecated_exports_in_use: bool,
790
791 #[arg(long)]
793 unused_enum_members: bool,
794
795 #[arg(long)]
797 unused_class_members: bool,
798
799 #[arg(long)]
801 unused_store_members: bool,
802
803 #[arg(long)]
805 unprovided_injects: bool,
806
807 #[arg(long)]
809 unrendered_components: bool,
810
811 #[arg(long)]
813 unused_component_props: bool,
814
815 #[arg(long)]
817 unused_component_emits: bool,
818
819 #[arg(long)]
821 unused_component_inputs: bool,
822
823 #[arg(long)]
825 unused_component_outputs: bool,
826
827 #[arg(long)]
829 unused_svelte_events: bool,
830
831 #[arg(long)]
833 unused_server_actions: bool,
834
835 #[arg(long)]
837 unused_load_data_keys: bool,
838
839 #[arg(long)]
841 unresolved_imports: bool,
842
843 #[arg(long)]
845 unlisted_deps: bool,
846
847 #[arg(long)]
849 duplicate_exports: bool,
850
851 #[arg(long)]
853 circular_deps: bool,
854
855 #[arg(long)]
857 re_export_cycles: bool,
858
859 #[arg(long)]
861 package_cycles: bool,
862
863 #[arg(long)]
865 boundary_violations: bool,
866
867 #[arg(long)]
869 policy_violations: bool,
870
871 #[arg(long)]
873 stale_suppressions: bool,
874
875 #[arg(long)]
877 unused_catalog_entries: bool,
878
879 #[arg(long)]
881 empty_catalog_groups: bool,
882
883 #[arg(long)]
885 unresolved_catalog_references: bool,
886
887 #[arg(long)]
889 unused_dependency_overrides: bool,
890
891 #[arg(long)]
893 misconfigured_dependency_overrides: bool,
894
895 #[arg(long)]
897 include_dupes: bool,
898
899 #[arg(long, value_name = "FILE:EXPORT")]
901 trace: Option<String>,
902
903 #[arg(long, value_name = "PATH")]
905 trace_file: Option<String>,
906
907 #[arg(long, value_name = "PACKAGE")]
909 trace_dependency: Option<String>,
910
911 #[arg(long, value_name = "PATH")]
915 impact_closure: Option<String>,
916
917 #[arg(long, value_name = "FILE:EXPORT")]
919 symbol_impact: Option<String>,
920
921 #[arg(long)]
926 top: Option<usize>,
927
928 #[arg(long, value_name = "PATH")]
932 file: Vec<std::path::PathBuf>,
933
934 #[arg(long = "finding-id", value_name = "ID", value_delimiter = ',')]
939 finding_id: Vec<String>,
940
941 #[arg(value_name = "PATH")]
944 path: Option<std::path::PathBuf>,
945 },
946
947 Watch {
949 #[arg(long)]
951 no_clear: bool,
952 },
953
954 TypeAware {
956 #[command(subcommand)]
957 subcommand: TypeAwareCli,
958 },
959
960 #[command(override_help = doctor::HELP)]
967 Doctor,
968
969 SimilarCode {
975 #[command(subcommand)]
976 subcommand: Option<similar_code_cli::SimilarCodeSubcommand>,
977 #[arg(long, value_name = "0..1")]
979 threshold: Option<f64>,
980 #[arg(long, value_name = "N")]
982 min_lines: Option<usize>,
983 #[arg(long, value_name = "N")]
985 top: Option<usize>,
986 #[arg(long, value_name = "PATH")]
988 file: Vec<PathBuf>,
989
990 #[arg(value_name = "PATH")]
993 path: Option<PathBuf>,
994 },
995
996 Inspect {
998 #[arg(
1000 long,
1001 value_name = "PATH",
1002 conflicts_with = "symbol",
1003 required_unless_present = "symbol"
1004 )]
1005 file: Option<String>,
1006
1007 #[arg(long, value_name = "FILE:EXPORT", conflicts_with = "file")]
1009 symbol: Option<String>,
1010
1011 #[arg(long)]
1016 symbol_chain: bool,
1017
1018 #[arg(long)]
1021 churn: bool,
1022 },
1023
1024 Trace {
1039 #[arg(value_name = "FILE:SYMBOL", required_unless_present = "path")]
1042 symbol: Option<String>,
1043
1044 #[arg(
1048 long,
1049 num_args = 2,
1050 value_names = ["FROM", "TO"],
1051 conflicts_with_all = ["symbol", "callers", "callees", "depth"]
1052 )]
1053 path: Vec<String>,
1054
1055 #[arg(long, requires = "path")]
1061 eager_only: bool,
1062
1063 #[arg(long)]
1066 callers: bool,
1067
1068 #[arg(long)]
1071 callees: bool,
1072
1073 #[arg(long, value_name = "N")]
1076 depth: Option<u32>,
1077 },
1078
1079 #[command(name = "trace-error")]
1098 TraceError {
1099 #[arg(value_name = "FILE")]
1103 trace_file: Option<String>,
1104 },
1105
1106 Fix {
1121 #[arg(long)]
1123 dry_run: bool,
1124
1125 #[arg(long, alias = "force")]
1127 yes: bool,
1128
1129 #[arg(long)]
1136 no_create_config: bool,
1137
1138 #[arg(value_name = "PATH")]
1142 path: Option<PathBuf>,
1143 },
1144
1145 Init {
1154 #[arg(long)]
1156 toml: bool,
1157
1158 #[arg(long, conflicts_with_all = ["toml", "hooks", "branch"])]
1160 agents: bool,
1161
1162 #[arg(long)]
1166 hooks: bool,
1167
1168 #[arg(long, requires = "hooks")]
1170 branch: Option<String>,
1171
1172 #[arg(long, conflicts_with_all = ["toml", "agents", "hooks", "branch"])]
1176 decline: bool,
1177 },
1178
1179 Hooks {
1186 #[command(subcommand)]
1187 subcommand: HooksCli,
1188 },
1189
1190 Agent {
1196 #[command(subcommand)]
1197 subcommand: AgentCli,
1198 },
1199
1200 Ci {
1202 #[command(subcommand)]
1203 subcommand: CiCli,
1204 },
1205
1206 ConfigSchema,
1208
1209 PluginSchema,
1211
1212 PluginCheck,
1214
1215 RulePackSchema,
1217
1218 RulePack {
1220 #[command(subcommand)]
1221 subcommand: RulePackCli,
1222 },
1223
1224 Guard {
1226 #[arg(required = true, num_args = 1..)]
1228 files: Vec<String>,
1229 },
1230
1231 Config {
1249 #[arg(long)]
1251 path: bool,
1252 },
1253
1254 Recommend,
1262
1263 List {
1266 #[arg(long)]
1268 entry_points: bool,
1269
1270 #[arg(long)]
1272 files: bool,
1273
1274 #[arg(long)]
1276 plugins: bool,
1277
1278 #[arg(long)]
1280 boundaries: bool,
1281
1282 #[arg(long)]
1286 workspaces: bool,
1287
1288 #[arg(long)]
1298 entry_weight: bool,
1299
1300 #[arg(value_name = "PATH")]
1303 path: Option<PathBuf>,
1304 },
1305
1306 Workspaces,
1312
1313 Dupes {
1315 #[arg(long)]
1318 mode: Option<DupesMode>,
1319
1320 #[arg(long)]
1322 near: bool,
1323
1324 #[arg(long)]
1327 min_tokens: Option<usize>,
1328
1329 #[arg(long)]
1332 min_lines: Option<usize>,
1333
1334 #[arg(long, value_parser = parse_min_occurrences)]
1339 min_occurrences: Option<usize>,
1340
1341 #[arg(long)]
1344 threshold: Option<f64>,
1345
1346 #[arg(long)]
1348 skip_local: bool,
1349
1350 #[arg(long)]
1352 cross_language: bool,
1353
1354 #[arg(long)]
1358 ignore_imports: bool,
1359
1360 #[arg(long, conflicts_with = "ignore_imports")]
1363 no_ignore_imports: bool,
1364
1365 #[arg(long)]
1375 top: Option<usize>,
1376
1377 #[arg(long)]
1381 no_fragments: bool,
1382
1383 #[arg(long, value_name = "FILE:LINE")]
1385 trace: Option<String>,
1386
1387 #[arg(value_name = "PATH")]
1390 path: Option<PathBuf>,
1391 },
1392
1393 Health {
1399 #[arg(long)]
1401 max_cyclomatic: Option<u16>,
1402
1403 #[arg(long)]
1405 max_cognitive: Option<u16>,
1406
1407 #[arg(long)]
1411 max_crap: Option<f64>,
1412
1413 #[arg(long)]
1415 top: Option<usize>,
1416
1417 #[arg(long, default_value = "cyclomatic")]
1419 sort: SortBy,
1420
1421 #[arg(long)]
1424 complexity: bool,
1425
1426 #[arg(long)]
1433 complexity_breakdown: bool,
1434
1435 #[arg(long)]
1440 file_scores: bool,
1441
1442 #[arg(long)]
1445 coverage_gaps: bool,
1446
1447 #[arg(long)]
1450 hotspots: bool,
1451
1452 #[arg(long)]
1456 ownership: bool,
1457
1458 #[arg(long, value_name = "MODE", value_enum)]
1463 ownership_emails: Option<EmailModeArg>,
1464
1465 #[arg(long)]
1468 targets: bool,
1469
1470 #[arg(long)]
1473 type_coupling: bool,
1474
1475 #[arg(long)]
1480 css: bool,
1481
1482 #[arg(long, value_enum)]
1485 effort: Option<EffortFilter>,
1486
1487 #[arg(long)]
1490 score: bool,
1491
1492 #[arg(long, value_name = "N")]
1501 min_score: Option<f64>,
1502
1503 #[arg(long, value_name = "LEVEL", value_enum)]
1509 min_severity: Option<HealthSeverityCli>,
1510
1511 #[arg(long)]
1515 report_only: bool,
1516
1517 #[arg(long, value_name = "DURATION")]
1520 since: Option<String>,
1521
1522 #[arg(long, value_name = "N")]
1524 min_commits: Option<u32>,
1525
1526 #[expect(
1531 clippy::option_option,
1532 reason = "clap pattern: None=not passed, Some(None)=flag only, Some(Some(path))=with value"
1533 )]
1534 #[arg(long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
1535 save_snapshot: Option<Option<String>>,
1536
1537 #[arg(long)]
1541 trend: bool,
1542
1543 #[arg(long, value_name = "PATH")]
1556 coverage: Option<PathBuf>,
1557
1558 #[arg(long, value_name = "PATH")]
1564 coverage_root: Option<PathBuf>,
1565
1566 #[arg(long, value_name = "PATH")]
1572 runtime_coverage: Option<PathBuf>,
1573
1574 #[arg(long, default_value_t = 100)]
1576 min_invocations_hot: u64,
1577
1578 #[arg(long, value_name = "N")]
1584 min_observation_volume: Option<u32>,
1585
1586 #[arg(long, value_name = "RATIO")]
1591 low_traffic_threshold: Option<f64>,
1592
1593 #[arg(value_name = "PATH")]
1596 path: Option<PathBuf>,
1597 },
1598
1599 Flags {
1606 #[arg(long)]
1608 top: Option<usize>,
1609
1610 #[arg(long)]
1613 retirement: bool,
1614
1615 #[arg(long = "reason", value_name = "CODE", requires = "retirement")]
1617 reasons: Vec<flags::RetirementReasonArg>,
1618
1619 #[arg(
1621 long,
1622 value_name = "KEY",
1623 requires = "retirement",
1624 default_value = "age"
1625 )]
1626 sort: flags::RetirementSortArg,
1627
1628 #[arg(
1631 long,
1632 value_name = "MODE",
1633 requires = "retirement",
1634 default_value = "blame"
1635 )]
1636 flag_age: flags::FlagAgeArg,
1637
1638 #[arg(long, value_name = "DAYS", requires = "retirement")]
1640 min_age: Option<u64>,
1641
1642 #[arg(long, value_name = "FILE", requires = "retirement")]
1646 flag_state: Option<std::path::PathBuf>,
1647
1648 #[arg(long, value_name = "DAYS", requires = "retirement")]
1651 max_flag_age: Option<u64>,
1652 },
1653
1654 Suppressions {
1664 #[arg(long, value_name = "PATH")]
1666 file: Vec<std::path::PathBuf>,
1667 },
1668
1669 Explain {
1675 #[arg(required = true, num_args = 1.., value_name = "ISSUE_TYPE")]
1677 issue_type: Vec<String>,
1678 },
1679
1680 #[command(visible_alias = "review")]
1705 Audit {
1706 #[arg(long = "production-dead-code")]
1708 production_dead_code: bool,
1709
1710 #[arg(long = "production-health")]
1712 production_health: bool,
1713
1714 #[arg(long = "production-dupes")]
1716 production_dupes: bool,
1717
1718 #[arg(long)]
1721 dead_code_baseline: Option<PathBuf>,
1722
1723 #[arg(long)]
1726 health_baseline: Option<PathBuf>,
1727
1728 #[arg(long)]
1731 dupes_baseline: Option<PathBuf>,
1732
1733 #[arg(long)]
1737 max_crap: Option<f64>,
1738
1739 #[arg(long, value_name = "PATH")]
1744 coverage: Option<PathBuf>,
1745
1746 #[arg(long, value_name = "PATH")]
1750 coverage_root: Option<PathBuf>,
1751
1752 #[arg(long = "no-css")]
1754 no_css: bool,
1755
1756 #[arg(long)]
1760 css_deep: bool,
1761
1762 #[arg(long = "no-css-deep")]
1764 no_css_deep: bool,
1765
1766 #[arg(long, value_enum)]
1772 gate: Option<AuditGateArg>,
1773
1774 #[arg(long, value_name = "PATH")]
1783 runtime_coverage: Option<PathBuf>,
1784
1785 #[arg(long, default_value_t = 100)]
1788 min_invocations_hot: u64,
1789
1790 #[arg(long, value_name = "MARKER", hide = true)]
1795 gate_marker: Option<String>,
1796
1797 #[arg(long)]
1803 brief: bool,
1804
1805 #[arg(
1810 long,
1811 value_name = "N",
1812 default_value_t = audit_decision_surface::DEFAULT_DECISION_CAP
1813 )]
1814 max_decisions: usize,
1815
1816 #[arg(long, conflicts_with_all = ["walkthrough_file", "walkthrough"])]
1824 walkthrough_guide: bool,
1825
1826 #[arg(long, value_name = "PATH")]
1836 walkthrough_file: Option<PathBuf>,
1837
1838 #[arg(long, conflicts_with_all = ["walkthrough_guide", "walkthrough_file"])]
1844 walkthrough: bool,
1845
1846 #[arg(long, value_name = "PATH")]
1852 mark_viewed: Vec<PathBuf>,
1853
1854 #[arg(long)]
1858 show_cleared: bool,
1859
1860 #[arg(long)]
1866 show_deprioritized: bool,
1867
1868 #[arg(value_name = "PATH")]
1871 path: Option<PathBuf>,
1872 },
1873
1874 AuditCache {
1876 #[command(subcommand)]
1877 subcommand: AuditCacheCli,
1878 },
1879
1880 DecisionSurface {
1895 #[arg(
1898 long,
1899 value_name = "N",
1900 default_value_t = audit_decision_surface::DEFAULT_DECISION_CAP
1901 )]
1902 max_decisions: usize,
1903 },
1904
1905 Impact {
1915 #[command(subcommand)]
1916 subcommand: Option<ImpactCli>,
1917 #[arg(long)]
1921 all: bool,
1922 #[arg(long, value_enum, default_value_t = ImpactSortCli::Recent)]
1924 sort: ImpactSortCli,
1925 #[arg(long)]
1928 limit: Option<usize>,
1929 },
1930
1931 Security {
1962 #[command(subcommand)]
1963 subcommand: Option<SecuritySubcommand>,
1964 #[arg(long, value_name = "PATH")]
1971 runtime_coverage: Option<PathBuf>,
1972 #[arg(long, default_value_t = 100)]
1975 min_invocations_hot: u64,
1976 #[arg(long, value_name = "PATH")]
1980 file: Vec<std::path::PathBuf>,
1981 #[arg(long, value_name = "MODE")]
1987 gate: Option<security::SecurityGateArg>,
1988 #[arg(long)]
1990 surface: bool,
1991
1992 #[arg(value_name = "PATH")]
1995 path: Option<PathBuf>,
1996 },
1997
1998 Report {
2003 #[arg(long, value_name = "PATH")]
2006 from: PathBuf,
2007 },
2008 Schema,
2010
2011 CiTemplate {
2018 #[command(subcommand)]
2019 subcommand: CiTemplateCli,
2020 },
2021
2022 Migrate {
2024 #[arg(long, conflicts_with = "jsonc")]
2026 toml: bool,
2027
2028 #[arg(long)]
2036 jsonc: bool,
2037
2038 #[arg(long)]
2040 dry_run: bool,
2041
2042 #[arg(long, value_name = "PATH")]
2044 from: Option<PathBuf>,
2045 },
2046
2047 License {
2054 #[command(subcommand)]
2055 subcommand: LicenseCli,
2056 },
2057
2058 Telemetry {
2066 #[command(subcommand)]
2067 subcommand: TelemetryCli,
2068 },
2069
2070 Coverage {
2075 #[command(subcommand)]
2076 subcommand: CoverageCli,
2077 },
2078
2079 SetupHooks {
2091 #[arg(long, value_enum)]
2093 agent: Option<setup_hooks::HookAgentArg>,
2094
2095 #[arg(long)]
2097 dry_run: bool,
2098
2099 #[arg(long)]
2102 force: bool,
2103
2104 #[arg(long)]
2106 user: bool,
2107
2108 #[arg(long)]
2110 gitignore_claude: bool,
2111
2112 #[arg(long)]
2116 uninstall: bool,
2117 },
2118
2119 Viz {
2121 #[arg(long = "out", value_name = "PATH")]
2123 output: Option<PathBuf>,
2124
2125 #[arg(long)]
2127 no_open: bool,
2128
2129 #[arg(long = "viz-format", default_value = "html")]
2131 viz_format: viz::VizFormat,
2132 },
2133}
2134
2135#[derive(Subcommand)]
2136enum SecuritySubcommand {
2137 Survivors {
2139 #[arg(long, value_name = "PATH")]
2141 candidates: PathBuf,
2142 #[arg(long, value_name = "PATH")]
2144 verdicts: PathBuf,
2145 #[arg(long)]
2147 require_verdict_for_each_candidate: bool,
2148 },
2149 #[command(name = "blind-spots")]
2151 BlindSpots {
2152 #[arg(long, value_name = "PATH")]
2154 file: Vec<PathBuf>,
2155 },
2156}
2157
2158#[derive(clap::Subcommand)]
2159enum AuditCacheCli {
2160 Remove {
2166 #[arg(long)]
2168 dry_run: bool,
2169
2170 #[arg(long, alias = "force")]
2172 yes: bool,
2173 },
2174
2175 Prune {
2188 #[arg(long)]
2190 dry_run: bool,
2191
2192 #[arg(long, value_name = "N")]
2199 max_age_days: Option<u32>,
2200 },
2201}
2202
2203#[derive(clap::Subcommand)]
2204enum LicenseCli {
2205 Activate {
2210 #[arg(value_name = "JWT")]
2212 jwt: Option<String>,
2213
2214 #[arg(long, value_name = "PATH")]
2216 from_file: Option<PathBuf>,
2217
2218 #[arg(long, conflicts_with_all = ["jwt", "from_file"])]
2220 stdin: bool,
2221
2222 #[arg(long, requires = "email")]
2229 trial: bool,
2230
2231 #[arg(long, value_name = "ADDR")]
2233 email: Option<String>,
2234 },
2235 Status,
2237 Refresh {
2243 #[arg(long, value_name = "KEY")]
2253 api_key: Option<String>,
2254 },
2255 Deactivate,
2257}
2258
2259#[derive(Clone, Copy, clap::Subcommand)]
2260enum TelemetryCli {
2261 Status,
2263 Enable,
2265 Disable,
2267 Inspect {
2269 #[arg(long)]
2271 example: bool,
2272 },
2273}
2274
2275#[derive(clap::Subcommand)]
2276enum CiTemplateCli {
2277 Gitlab {
2279 #[arg(long, value_name = "DIR", num_args = 0..=1, default_missing_value = ".")]
2283 vendor: Option<PathBuf>,
2284
2285 #[arg(long)]
2287 force: bool,
2288 },
2289}
2290
2291#[derive(clap::Subcommand)]
2292enum CoverageCli {
2293 Setup {
2295 #[arg(short = 'y', long)]
2297 yes: bool,
2298
2299 #[arg(long)]
2301 non_interactive: bool,
2302
2303 #[arg(long)]
2305 json: bool,
2306 },
2307 Analyze {
2313 #[arg(long, value_name = "PATH", conflicts_with = "cloud")]
2317 runtime_coverage: Option<PathBuf>,
2318
2319 #[arg(long, visible_alias = "runtime-coverage-cloud")]
2321 cloud: bool,
2322
2323 #[arg(long, value_name = "KEY")]
2325 api_key: Option<String>,
2326
2327 #[arg(long, value_name = "URL")]
2329 api_endpoint: Option<String>,
2330
2331 #[arg(long, value_name = "OWNER/REPO")]
2337 repo: Option<String>,
2338
2339 #[arg(long, value_name = "ID")]
2341 project_id: Option<String>,
2342
2343 #[arg(long, value_name = "DAYS", default_value_t = 30)]
2345 coverage_period: u16,
2346
2347 #[arg(long, value_name = "ENV")]
2349 environment: Option<String>,
2350
2351 #[arg(long, value_name = "SHA")]
2353 commit_sha: Option<String>,
2354
2355 #[arg(long)]
2357 production: bool,
2358
2359 #[arg(long, default_value_t = 100)]
2361 min_invocations_hot: u64,
2362
2363 #[arg(long, value_name = "N")]
2365 min_observation_volume: Option<u32>,
2366
2367 #[arg(long, value_name = "RATIO")]
2369 low_traffic_threshold: Option<f64>,
2370
2371 #[arg(long)]
2373 top: Option<usize>,
2374
2375 #[arg(long)]
2377 blast_radius: bool,
2378
2379 #[arg(long)]
2381 importance: bool,
2382
2383 #[arg(long)]
2385 debug_unmatched: bool,
2386 },
2387 UploadInventory {
2398 #[arg(long, value_name = "KEY")]
2407 api_key: Option<String>,
2408
2409 #[arg(long, value_name = "URL")]
2414 api_endpoint: Option<String>,
2415
2416 #[arg(long, value_name = "PROJECT_ID")]
2421 project_id: Option<String>,
2422
2423 #[arg(long, value_name = "SHA")]
2428 git_sha: Option<String>,
2429
2430 #[arg(long)]
2436 allow_dirty: bool,
2437
2438 #[arg(long, value_name = "GLOB", num_args = 0..)]
2442 exclude_paths: Vec<String>,
2443
2444 #[arg(long, value_name = "PREFIX")]
2457 path_prefix: Option<String>,
2458
2459 #[arg(long)]
2461 dry_run: bool,
2462
2463 #[arg(long)]
2469 with_callers: bool,
2470
2471 #[arg(long)]
2475 ignore_upload_errors: bool,
2476 },
2477 UploadSourceMaps {
2490 #[arg(long, value_name = "PATH", default_value = "dist")]
2492 dir: PathBuf,
2493
2494 #[arg(long, value_name = "GLOB", default_value = "**/*.map")]
2496 include: String,
2497
2498 #[arg(long, value_name = "GLOB", default_value = "**/node_modules/**")]
2502 exclude: Vec<String>,
2503
2504 #[arg(long, value_name = "NAME")]
2508 repo: Option<String>,
2509
2510 #[arg(long, value_name = "SHA")]
2515 git_sha: Option<String>,
2516
2517 #[arg(long, value_name = "URL")]
2519 endpoint: Option<String>,
2520
2521 #[arg(long, value_name = "BOOL", default_value_t = true, action = clap::ArgAction::Set)]
2526 strip_path: bool,
2527
2528 #[arg(long)]
2530 dry_run: bool,
2531
2532 #[arg(long, value_name = "N", default_value_t = 4)]
2534 concurrency: usize,
2535
2536 #[arg(long)]
2538 fail_fast: bool,
2539 },
2540 UploadStaticFindings {
2547 #[arg(long, value_name = "KEY")]
2557 api_key: Option<String>,
2558
2559 #[arg(long, value_name = "URL")]
2564 api_endpoint: Option<String>,
2565
2566 #[arg(long, value_name = "PROJECT_ID")]
2571 project_id: Option<String>,
2572
2573 #[arg(long, value_name = "SHA")]
2578 git_sha: Option<String>,
2579
2580 #[arg(long)]
2586 allow_dirty: bool,
2587
2588 #[arg(long)]
2590 dry_run: bool,
2591
2592 #[arg(long)]
2596 ignore_upload_errors: bool,
2597 },
2598 ReviewPacket {
2609 #[arg(long, value_name = "KEY")]
2611 api_key: Option<String>,
2612
2613 #[arg(long, value_name = "URL")]
2615 api_endpoint: Option<String>,
2616
2617 #[arg(long, value_name = "OWNER/REPO")]
2622 repo: Option<String>,
2623
2624 #[arg(long, value_name = "ID")]
2626 project_id: Option<String>,
2627
2628 #[arg(long, value_name = "DAYS")]
2630 coverage_period: Option<u16>,
2631
2632 #[arg(long, value_name = "SHA")]
2634 commit_sha: Option<String>,
2635
2636 #[arg(long = "file", value_name = "PATH")]
2638 files: Vec<String>,
2639
2640 #[arg(long = "function", value_name = "FILE:NAME[:LINE]")]
2642 functions: Vec<String>,
2643 },
2644 DeploymentChanges {
2652 #[arg(long, value_name = "KEY")]
2654 api_key: Option<String>,
2655
2656 #[arg(long, value_name = "URL")]
2658 api_endpoint: Option<String>,
2659
2660 #[arg(long, value_name = "OWNER/REPO")]
2665 repo: Option<String>,
2666
2667 #[arg(long, value_name = "SHA")]
2669 sha: Option<String>,
2670
2671 #[arg(long, value_name = "KIND", value_parser = [
2673 "stopped", "new_not_called", "heated_up", "cooled_down", "new_called", "unchanged",
2674 ])]
2675 change: Option<String>,
2676
2677 #[arg(long, value_name = "N")]
2679 limit: Option<u16>,
2680
2681 #[arg(long, value_name = "CURSOR")]
2683 cursor: Option<String>,
2684 },
2685}
2686
2687#[derive(Subcommand)]
2688enum CiCli {
2689 PlanPrComment {
2691 #[arg(long)]
2693 body: PathBuf,
2694
2695 #[arg(long)]
2697 marker_id: String,
2698
2699 #[arg(long)]
2701 clean: bool,
2702
2703 #[arg(long)]
2705 existing_comment_id: Option<String>,
2706
2707 #[arg(long)]
2709 existing_body: Option<PathBuf>,
2710 },
2711
2712 PostPrComment {
2714 #[arg(long, value_enum)]
2716 provider: CiProviderArg,
2717
2718 #[arg(long)]
2720 pr: Option<String>,
2721
2722 #[arg(long)]
2724 mr: Option<String>,
2725
2726 #[arg(long)]
2728 body: PathBuf,
2729
2730 #[arg(long)]
2732 envelope: Option<PathBuf>,
2733
2734 #[arg(long)]
2736 marker_id: String,
2737
2738 #[arg(long)]
2740 clean: bool,
2741
2742 #[arg(long)]
2744 repo: Option<String>,
2745
2746 #[arg(long = "project-id")]
2748 project_id: Option<String>,
2749
2750 #[arg(long = "api-url")]
2752 api_url: Option<String>,
2753
2754 #[arg(long)]
2756 dry_run: bool,
2757 },
2758
2759 PostReview {
2761 #[arg(long, value_enum)]
2763 provider: CiProviderArg,
2764
2765 #[arg(long)]
2767 pr: Option<String>,
2768
2769 #[arg(long)]
2771 mr: Option<String>,
2772
2773 #[arg(long)]
2775 envelope: PathBuf,
2776
2777 #[arg(long)]
2779 repo: Option<String>,
2780
2781 #[arg(long = "project-id")]
2783 project_id: Option<String>,
2784
2785 #[arg(long = "api-url")]
2787 api_url: Option<String>,
2788
2789 #[arg(long)]
2791 dry_run: bool,
2792 },
2793
2794 PostCheckRun {
2796 #[arg(long, value_enum)]
2798 provider: CiProviderArg,
2799
2800 #[arg(long)]
2802 decision: PathBuf,
2803
2804 #[arg(long)]
2806 repo: String,
2807
2808 #[arg(long = "head-sha")]
2810 head_sha: String,
2811
2812 #[arg(long = "api-url")]
2814 api_url: Option<String>,
2815
2816 #[arg(long = "split-gates")]
2818 split_gates: bool,
2819
2820 #[arg(long)]
2822 dry_run: bool,
2823 },
2824
2825 ReconcileReview {
2827 #[arg(long, value_enum)]
2829 provider: CiProviderArg,
2830
2831 #[arg(long)]
2833 pr: Option<String>,
2834
2835 #[arg(long)]
2837 mr: Option<String>,
2838
2839 #[arg(long)]
2841 envelope: PathBuf,
2842
2843 #[arg(long)]
2845 repo: Option<String>,
2846
2847 #[arg(long = "project-id")]
2849 project_id: Option<String>,
2850
2851 #[arg(long = "api-url")]
2853 api_url: Option<String>,
2854
2855 #[arg(long)]
2857 dry_run: bool,
2858 },
2859}
2860
2861#[derive(Subcommand)]
2862enum RulePackCli {
2863 Init {
2865 name: Option<String>,
2867
2868 #[arg(long, default_value = "starter")]
2870 template: String,
2871
2872 #[arg(long, default_value = "rule-packs")]
2874 dir: String,
2875
2876 #[arg(long)]
2878 no_config: bool,
2879 },
2880
2881 List,
2883
2884 Test {
2886 pack: Option<PathBuf>,
2888 },
2889
2890 Schema,
2892}
2893
2894#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
2896pub enum BaselineModeArg {
2897 #[default]
2899 Count,
2900 Identity,
2903}
2904
2905impl From<BaselineModeArg> for fallow_engine::baseline::HealthBaselineMode {
2906 fn from(value: BaselineModeArg) -> Self {
2907 match value {
2908 BaselineModeArg::Count => Self::Count,
2909 BaselineModeArg::Identity => Self::Identity,
2910 }
2911 }
2912}
2913
2914#[derive(Clone, Copy, Debug, clap::ValueEnum)]
2915enum CiProviderArg {
2916 Github,
2917 Gitlab,
2918}
2919
2920#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2922enum TypeAwareRequireArg {
2923 BestEffort,
2925 Complete,
2927}
2928
2929impl From<TypeAwareRequireArg> for fallow_config::TypeAwareRequire {
2930 fn from(value: TypeAwareRequireArg) -> Self {
2931 match value {
2932 TypeAwareRequireArg::BestEffort => Self::BestEffort,
2933 TypeAwareRequireArg::Complete => Self::Complete,
2934 }
2935 }
2936}
2937
2938#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2940pub enum EffortFilter {
2941 Low,
2942 Medium,
2943 High,
2944}
2945
2946impl EffortFilter {
2947 const fn to_estimate(self) -> fallow_output::EffortEstimate {
2949 match self {
2950 Self::Low => fallow_output::EffortEstimate::Low,
2951 Self::Medium => fallow_output::EffortEstimate::Medium,
2952 Self::High => fallow_output::EffortEstimate::High,
2953 }
2954 }
2955}
2956
2957#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2959pub enum HealthSeverityCli {
2960 Moderate,
2961 High,
2962 Critical,
2963}
2964
2965impl HealthSeverityCli {
2966 const fn to_health_severity(self) -> fallow_output::FindingSeverity {
2968 match self {
2969 Self::Moderate => fallow_output::FindingSeverity::Moderate,
2970 Self::High => fallow_output::FindingSeverity::High,
2971 Self::Critical => fallow_output::FindingSeverity::Critical,
2972 }
2973 }
2974}
2975
2976#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2982pub enum EmailModeArg {
2983 Raw,
2985 Handle,
2987 Anonymized,
2989 #[value(hide = true)]
2991 Hash,
2992}
2993
2994impl EmailModeArg {
2995 const fn to_config(self) -> fallow_config::EmailMode {
2997 match self {
2998 Self::Raw => fallow_config::EmailMode::Raw,
2999 Self::Handle => fallow_config::EmailMode::Handle,
3000 Self::Anonymized => fallow_config::EmailMode::Anonymized,
3001 Self::Hash => fallow_config::EmailMode::Hash,
3002 }
3003 }
3004}
3005
3006#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
3008pub enum AuditGateArg {
3009 NewOnly,
3011 All,
3013}
3014
3015impl From<AuditGateArg> for fallow_config::AuditGate {
3016 fn from(value: AuditGateArg) -> Self {
3017 match value {
3018 AuditGateArg::NewOnly => Self::NewOnly,
3019 AuditGateArg::All => Self::All,
3020 }
3021 }
3022}
3023
3024fn parse_min_occurrences(s: &str) -> Result<usize, String> {
3028 let value: usize = s
3029 .parse()
3030 .map_err(|_| format!("`{s}` is not a non-negative integer"))?;
3031 if value < 2 {
3032 return Err(format!(
3033 "must be at least 2 (got {value}); a single occurrence isn't a duplicate"
3034 ));
3035 }
3036 Ok(value)
3037}
3038
3039fn resolve_audit_baseline_path(
3045 root: &std::path::Path,
3046 cli: Option<&std::path::Path>,
3047 config: Option<&str>,
3048) -> Option<PathBuf> {
3049 let path = cli.map(std::path::Path::to_path_buf).or_else(|| {
3050 config.map(|p| {
3051 let path = PathBuf::from(p);
3052 if path_util::is_absolute_path_any_platform(&path) {
3053 path
3054 } else {
3055 root.join(path)
3056 }
3057 })
3058 })?;
3059 if path_util::is_absolute_path_any_platform(&path) {
3060 Some(path)
3061 } else {
3062 Some(root.join(path))
3063 }
3064}
3065
3066fn emit_known_failure(
3067 message: &str,
3068 exit_code: u8,
3069 output: fallow_config::OutputFormat,
3070 reason: telemetry::FailureReason,
3071) -> ExitCode {
3072 telemetry::note_failure_reason(reason);
3073 emit_error(message, exit_code, output)
3074}
3075
3076fn emit_known_failure_with_style(
3077 message: &str,
3078 exit_code: u8,
3079 output: fallow_config::OutputFormat,
3080 json_style: json_style::JsonStyle,
3081 reason: telemetry::FailureReason,
3082) -> ExitCode {
3083 telemetry::note_failure_reason(reason);
3084 error::emit_error_with_style(message, exit_code, output, json_style)
3085}
3086
3087fn unsupported_security_global(cli: &Cli) -> Option<&'static str> {
3088 if cli.baseline.is_some() {
3089 Some("--baseline")
3090 } else if cli.save_baseline.is_some() {
3091 Some("--save-baseline")
3092 } else if cli.fail_on_stale_baseline {
3093 Some("--fail-on-stale-baseline")
3094 } else if cli.fail_on_baseline_growth {
3095 Some("--fail-on-baseline-growth")
3096 } else if cli.baseline_base.is_some() {
3097 Some("--baseline-base")
3098 } else if cli.fail_on_parse_error {
3099 Some("--fail-on-parse-error")
3100 } else if cli.production {
3101 Some("--production")
3102 } else if cli.no_production {
3103 Some("--no-production")
3104 } else if cli.no_package_baselines {
3105 Some("--no-package-baselines")
3106 } else if cli.group_by.is_some() {
3107 Some("--group-by")
3108 } else if cli.performance {
3109 Some("--performance")
3110 } else if cli.explain_skipped {
3111 Some("--explain-skipped")
3112 } else if cli.fail_on_regression {
3113 Some("--fail-on-regression")
3114 } else if cli.regression_baseline.is_some() {
3115 Some("--regression-baseline")
3116 } else if cli.save_regression_baseline.is_some() {
3117 Some("--save-regression-baseline")
3118 } else if cli.dupes_mode.is_some() {
3119 Some("--dupes-mode")
3120 } else if cli.dupes_threshold.is_some() {
3121 Some("--dupes-threshold")
3122 } else if cli.dupes_min_tokens.is_some() {
3123 Some("--dupes-min-tokens")
3124 } else if cli.dupes_min_lines.is_some() {
3125 Some("--dupes-min-lines")
3126 } else if cli.dupes_min_occurrences.is_some() {
3127 Some("--dupes-min-occurrences")
3128 } else if cli.dupes_skip_local {
3129 Some("--dupes-skip-local")
3130 } else if cli.dupes_cross_language {
3131 Some("--dupes-cross-language")
3132 } else if cli.dupes_ignore_imports {
3133 Some("--dupes-ignore-imports")
3134 } else if cli.dupes_no_ignore_imports {
3135 Some("--dupes-no-ignore-imports")
3136 } else if cli.include_entry_exports {
3137 Some("--include-entry-exports")
3138 } else {
3139 None
3140 }
3141}
3142
3143struct DispatchContext<'a> {
3144 cli: &'a Cli,
3145 root: &'a std::path::Path,
3146 output: fallow_config::OutputFormat,
3147 quiet: bool,
3148 fail_on_issues: bool,
3149 json_style: json_style::JsonStyle,
3150 threads: usize,
3151 tolerance: regression::Tolerance,
3152 save_regression_file: Option<&'a std::path::PathBuf>,
3153 save_to_config: bool,
3154}
3155
3156impl DispatchContext<'_> {
3157 fn production_modes(
3158 &self,
3159 dead_code: bool,
3160 health: bool,
3161 dupes: bool,
3162 ) -> Result<ProductionModes, ExitCode> {
3163 resolve_production_modes(self.cli, self.root, self.output, dead_code, health, dupes)
3164 }
3165
3166 fn production_for(
3167 &self,
3168 analysis: fallow_config::ProductionAnalysis,
3169 ) -> Result<bool, ExitCode> {
3170 self.production_modes(false, false, false)
3171 .map(|modes| modes.for_analysis(analysis))
3172 }
3173
3174 fn growth_flags(&self) -> baseline_growth::GrowthFlags<'_> {
3175 baseline_growth::GrowthFlags {
3176 enabled: self.cli.fail_on_baseline_growth,
3177 base: self.cli.baseline_base.as_deref(),
3178 changed_since: self.cli.changed_since.as_deref(),
3179 }
3180 }
3181
3182 fn growth_ctx(
3183 &self,
3184 owner: baseline_growth::GrowthOwner,
3185 ) -> baseline_growth::GrowthContext<'_> {
3186 baseline_growth::GrowthContext {
3187 root: self.root,
3188 flags: self.growth_flags(),
3189 owner,
3190 output: self.output,
3191 json_style: self.json_style,
3192 }
3193 }
3194
3195 fn regression_opts(&self, scoped: bool) -> regression::RegressionOpts<'_> {
3196 regression::RegressionOpts {
3197 fail_on_regression: self.cli.fail_on_regression,
3198 tolerance: self.tolerance,
3199 regression_baseline_file: self.cli.regression_baseline.as_deref(),
3200 save_target: if let Some(path) = self.save_regression_file {
3201 regression::SaveRegressionTarget::File(path)
3202 } else if self.save_to_config {
3203 regression::SaveRegressionTarget::Config
3204 } else {
3205 regression::SaveRegressionTarget::None
3206 },
3207 scoped,
3208 quiet: self.quiet,
3209 output: self.output,
3210 }
3211 }
3212}
3213
3214#[cfg(unix)]
3229fn signal_test_helper() -> ExitCode {
3230 use std::io::Write as _;
3231 use std::process::Command;
3232
3233 if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER_GRACEFUL").is_some() {
3234 signal::set_graceful_mode();
3235 }
3236
3237 let mut command = Command::new("sleep");
3238 command.arg("30");
3239 let child = match signal::ScopedChild::spawn(&mut command) {
3240 Ok(c) => c,
3241 Err(err) => {
3242 let _ = writeln!(std::io::stderr(), "spawn sleep failed: {err}");
3243 return ExitCode::from(2);
3244 }
3245 };
3246 let pid = child.id();
3247 let stdout = std::io::stdout();
3248 let mut lock = stdout.lock();
3249 let _ = writeln!(lock, "{pid}");
3250 let _ = lock.flush();
3251 drop(lock);
3252 let _ = child.wait_with_output();
3253 if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER_GRACEFUL").is_some() {
3254 return ExitCode::SUCCESS;
3255 }
3256 std::thread::sleep(std::time::Duration::from_secs(5));
3257 ExitCode::SUCCESS
3258}
3259
3260#[cfg(not(unix))]
3261fn signal_test_helper() -> ExitCode {
3262 ExitCode::from(2)
3263}
3264
3265fn install_spawn_hooks() {
3266 fallow_engine::churn::set_spawn_hook(signal::scoped_child::output);
3267 fallow_engine::changed_files::set_spawn_hook(signal::scoped_child::output);
3268}
3269
3270fn install_signal_handlers() {
3271 if let Err(err) = signal::install_handlers() {
3272 use std::io::Write as _;
3273 let stderr = std::io::stderr();
3274 let mut lock = stderr.lock();
3275 let _ = writeln!(lock, "fallow: failed to install signal handlers: {err}");
3276 }
3277}
3278
3279fn redirect_report_to_file(
3284 path: &std::path::Path,
3285 output: fallow_config::OutputFormat,
3286) -> Result<(), ExitCode> {
3287 match fallow_engine::write_guard::create_file(
3288 path,
3289 fallow_engine::write_guard::WriteTarget::Path,
3290 ) {
3291 Ok(file) => {
3292 report::sink::set_file_sink(file);
3293 colored::control::set_override(false);
3294 Ok(())
3295 }
3296 Err(e) if e.is_directory() => Err(emit_error(
3297 &format!(
3298 "failed to create the directory of {} for --output-file: {e}",
3299 path.display()
3300 ),
3301 2,
3302 output,
3303 )),
3304 Err(e) => Err(emit_error(
3305 &format!("failed to open {} for --output-file: {e}", path.display()),
3306 2,
3307 output,
3308 )),
3309 }
3310}
3311
3312fn finalize_report_file(
3315 path: &std::path::Path,
3316 quiet: bool,
3317 output: fallow_config::OutputFormat,
3318) -> Result<(), ExitCode> {
3319 if let Err(e) = report::sink::flush() {
3320 return Err(emit_error(
3321 &format!("failed to write {}: {e}", path.display()),
3322 2,
3323 output,
3324 ));
3325 }
3326 if !quiet && report::sink::wrote() {
3330 eprintln!("Report written to {}", path.display());
3331 }
3332 Ok(())
3333}
3334
3335pub fn run() -> ExitCode {
3340 process_clock::mark_process_start();
3341 install_signal_handlers();
3342 install_spawn_hooks();
3343
3344 if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER").is_some() {
3345 return signal_test_helper();
3346 }
3347
3348 let (mut cli, fmt) = match parse_cli_args() {
3349 Ok(parsed) => parsed,
3350 Err(code) => return code,
3351 };
3352 if cli.pretty && !fmt.payload_is_json {
3353 eprintln!(
3354 "Error: --pretty requires JSON output. Use --format json --pretty, or remove --pretty."
3355 );
3356 return ExitCode::from(2);
3357 }
3358
3359 if let Some(code) = reject_global_baseline_flags(&cli, &fmt) {
3360 return code;
3361 }
3362
3363 if let Some(code) = run_schema_command_if_requested(&cli, fmt.json_style) {
3364 return code;
3365 }
3366
3367 if let Some(code) = run_telemetry_command_if_requested(&mut cli, fmt.output, fmt.json_style) {
3368 return code;
3369 }
3370 if let Some(code) = run_doctor_command_if_requested(&cli, &fmt) {
3371 return code;
3372 }
3373 if is_impact_statusline(&cli) {
3374 let (root, _) = match validate_inputs(&cli, fmt.output, fmt.json_style) {
3375 Ok(validated) => validated,
3376 Err(code) => return code,
3377 };
3378 return cli_impact::render_impact_statusline(&root);
3379 }
3380 let telemetry_run = start_telemetry_run(&cli, &fmt);
3381
3382 let (root, threads) = match validate_inputs(&cli, fmt.output, fmt.json_style) {
3383 Ok(v) => v,
3384 Err(code) => {
3385 return record_run_epilogue(telemetry_run, code, None, cli.parent_run.as_deref());
3386 }
3387 };
3388
3389 let FormatConfig {
3390 output,
3391 payload_is_json: _,
3392 quiet,
3393 fail_on_issues,
3394 json_style,
3395 } = fmt;
3396
3397 let tolerance =
3398 match run_pre_dispatch_checks(&cli, &root, output, json_style, quiet, telemetry_run) {
3399 Ok(tolerance) => tolerance,
3400 Err(code) => return code,
3401 };
3402
3403 if let Some(note) = write_scope::default_cache_dir_note(&cli, &root) {
3404 eprintln!("{note}");
3405 cli.no_cache = true;
3406 }
3407
3408 let (save_regression_file, save_to_config) = regression_save_targets(&cli);
3409
3410 let command = cli.command.take();
3411 process_clock::record_startup();
3412 let dispatch = DispatchContext {
3413 cli: &cli,
3414 root: &root,
3415 output,
3416 quiet,
3417 fail_on_issues,
3418 json_style,
3419 threads,
3420 tolerance,
3421 save_regression_file: save_regression_file.as_ref(),
3422 save_to_config,
3423 };
3424 let exit_code = match dispatch_and_finalize(&dispatch, command) {
3425 Ok(code) => code,
3426 Err(code) => return code,
3427 };
3428 record_run_epilogue(telemetry_run, exit_code, None, cli.parent_run.as_deref())
3429}
3430
3431#[doc(hidden)]
3435pub fn benchmark_fix_dry_run(root: &Path, threads: usize) -> (ExitCode, usize) {
3436 let config_path = None;
3437 fix::run_fix_with_count(&fix::FixOptions {
3438 root,
3439 config_path: &config_path,
3440 output: fallow_config::OutputFormat::Json,
3441 json_style: json_style::JsonStyle::Compact,
3442 no_cache: true,
3443 threads,
3444 quiet: true,
3445 emit_output: false,
3446 allow_remote_extends: false,
3447 dry_run: true,
3448 yes: false,
3449 production: false,
3450 no_create_config: true,
3451 type_aware: None,
3452 type_aware_projects: &[],
3453 type_aware_require: None,
3454 scope: None,
3455 })
3456}
3457
3458#[doc(hidden)]
3461pub use audit::AuditReviewBenchmarkCorpus;
3462
3463#[doc(hidden)]
3466pub fn create_audit_review_benchmark_corpus(
3467 root: &Path,
3468 changed_files: &[PathBuf],
3469 threads: usize,
3470) -> Result<AuditReviewBenchmarkCorpus, ExitCode> {
3471 audit::create_audit_review_benchmark_corpus(root, changed_files, threads)
3472}
3473
3474#[doc(hidden)]
3477pub fn benchmark_audit_review_brief_many_changed_files_json(
3478 corpus: &mut AuditReviewBenchmarkCorpus,
3479) -> (ExitCode, usize, usize, usize, usize, usize) {
3480 match audit::benchmark_audit_review_brief_many_changed_files_json(corpus) {
3481 Ok(result) => (
3482 ExitCode::SUCCESS,
3483 result.introduced_count,
3484 result.inherited_count,
3485 result.public_api_added_count,
3486 result.decision_count,
3487 result.rendered_bytes,
3488 ),
3489 Err(code) => (code, 0, 0, 0, 0, 0),
3490 }
3491}
3492
3493#[doc(hidden)]
3494pub use inspect::InspectBenchmarkCorpus;
3495
3496#[doc(hidden)]
3499pub fn create_inspect_benchmark_corpus(root: &Path, threads: usize) -> InspectBenchmarkCorpus {
3500 inspect::create_inspect_benchmark_corpus(root, threads)
3501}
3502
3503#[doc(hidden)]
3506pub fn benchmark_inspect_file_evidence_bundle_json(
3507 root: &Path,
3508 threads: usize,
3509 corpus: &InspectBenchmarkCorpus,
3510) -> (ExitCode, usize, usize) {
3511 match inspect::benchmark_inspect_file_evidence_bundle_json(root, threads, corpus) {
3512 Ok((child_call_count, rendered_bytes)) => {
3513 (ExitCode::SUCCESS, child_call_count, rendered_bytes)
3514 }
3515 Err(_) => (ExitCode::from(2), 0, 0),
3516 }
3517}
3518
3519#[doc(hidden)]
3522pub fn benchmark_dead_code_json(root: &Path, threads: usize) -> (ExitCode, usize, usize) {
3523 match check::benchmark_dead_code_json(root, threads) {
3524 Ok((issue_count, rendered_bytes)) => (ExitCode::SUCCESS, issue_count, rendered_bytes),
3525 Err(code) => (code, 0, 0),
3526 }
3527}
3528
3529#[doc(hidden)]
3532pub fn benchmark_security_json(root: &Path, threads: usize) -> (ExitCode, usize, usize) {
3533 match security::benchmark_security_json(root, threads) {
3534 Ok((finding_count, rendered_bytes)) => (ExitCode::SUCCESS, finding_count, rendered_bytes),
3535 Err(code) => (code, 0, 0),
3536 }
3537}
3538
3539#[doc(hidden)]
3540pub use security::{SecurityBlindSpotsBenchmarkResult, SecuritySurvivorsBenchmarkCorpus};
3541
3542#[doc(hidden)]
3545pub fn create_security_survivors_benchmark_corpus(
3546 root: &Path,
3547 threads: usize,
3548) -> Result<SecuritySurvivorsBenchmarkCorpus, ExitCode> {
3549 security::create_security_survivors_benchmark_corpus(root, threads)
3550}
3551
3552#[doc(hidden)]
3555pub fn benchmark_security_survivors_json(
3556 corpus: &SecuritySurvivorsBenchmarkCorpus,
3557) -> (ExitCode, usize, usize, usize, usize, usize) {
3558 match security::benchmark_security_survivors_json(corpus) {
3559 Ok((survivors, dismissed, needs_human_review, unverdicted, rendered_bytes)) => (
3560 ExitCode::SUCCESS,
3561 survivors,
3562 dismissed,
3563 needs_human_review,
3564 unverdicted,
3565 rendered_bytes,
3566 ),
3567 Err(_) => (ExitCode::from(2), 0, 0, 0, 0, 0),
3568 }
3569}
3570
3571#[doc(hidden)]
3574pub fn benchmark_security_blind_spots_json(
3575 root: &Path,
3576 diagnostics: &[fallow_types::results::SecurityUnresolvedCalleeDiagnostic],
3577) -> SecurityBlindSpotsBenchmarkResult {
3578 security::benchmark_security_blind_spots_json(root, diagnostics)
3579}
3580
3581#[doc(hidden)]
3584pub fn benchmark_list_json(root: &Path, threads: usize) -> (ExitCode, usize, usize, usize, usize) {
3585 match list::benchmark_list_json(root, threads) {
3586 Ok((file_count, entry_point_count, workspace_count, rendered_bytes)) => (
3587 ExitCode::SUCCESS,
3588 file_count,
3589 entry_point_count,
3590 workspace_count,
3591 rendered_bytes,
3592 ),
3593 Err(code) => (code, 0, 0, 0, 0),
3594 }
3595}
3596
3597#[doc(hidden)]
3600pub fn benchmark_list_boundaries_json(
3601 root: &Path,
3602 threads: usize,
3603) -> (ExitCode, usize, usize, usize, usize) {
3604 match list::benchmark_list_boundaries_json(root, threads) {
3605 Ok((zone_count, rule_count, matched_file_count, rendered_bytes)) => (
3606 ExitCode::SUCCESS,
3607 zone_count,
3608 rule_count,
3609 matched_file_count,
3610 rendered_bytes,
3611 ),
3612 Err(code) => (code, 0, 0, 0, 0),
3613 }
3614}
3615
3616#[doc(hidden)]
3619pub use watch::WatchFilterBenchmarkGlobalGitignore;
3620
3621#[doc(hidden)]
3624pub fn create_watch_filter_benchmark_global_gitignore() -> WatchFilterBenchmarkGlobalGitignore {
3625 watch::create_benchmark_global_gitignore()
3626}
3627
3628#[doc(hidden)]
3631pub fn benchmark_watch_filter_initialization(
3632 config: &fallow_config::ResolvedConfig,
3633 global_gitignore: &WatchFilterBenchmarkGlobalGitignore,
3634) -> (usize, usize) {
3635 watch::benchmark_filter_initialization(config, global_gitignore)
3636}
3637
3638#[doc(hidden)]
3641pub fn benchmark_viz_html(root: &Path, threads: usize) -> (ExitCode, usize, usize, usize) {
3642 match viz::benchmark_viz_html(root, threads) {
3643 Ok((file_count, edge_count, rendered_bytes)) => {
3644 (ExitCode::SUCCESS, file_count, edge_count, rendered_bytes)
3645 }
3646 Err(code) => (code, 0, 0, 0),
3647 }
3648}
3649
3650#[doc(hidden)]
3653pub fn benchmark_rule_pack_test_json(root: &Path, threads: usize) -> (ExitCode, usize, usize) {
3654 match rule_pack::benchmark_rule_pack_test_json(root, threads) {
3655 Ok((finding_count, rendered_bytes)) => (ExitCode::SUCCESS, finding_count, rendered_bytes),
3656 Err(code) => (code, 0, 0),
3657 }
3658}
3659
3660#[doc(hidden)]
3663pub fn benchmark_recommend_json(root: &Path) -> (ExitCode, usize, usize, bool, usize) {
3664 match onboarding::benchmark_recommend_json(root) {
3665 Ok((decision_count, framework_count, heterogeneous, rendered_bytes)) => (
3666 ExitCode::SUCCESS,
3667 decision_count,
3668 framework_count,
3669 heterogeneous,
3670 rendered_bytes,
3671 ),
3672 Err(_) => (ExitCode::from(2), 0, 0, false, 0),
3673 }
3674}
3675
3676#[doc(hidden)]
3679pub fn benchmark_runtime_coverage_analyze_json(
3680 root: &Path,
3681 runtime_coverage_path: &Path,
3682 response_bytes: &[u8],
3683 threads: usize,
3684) -> (ExitCode, usize, usize, usize, String) {
3685 match coverage::benchmark_local_json(root, runtime_coverage_path, response_bytes, threads) {
3686 Ok((finding_count, hot_path_count, request_bytes, rendered)) => (
3687 ExitCode::SUCCESS,
3688 finding_count,
3689 hot_path_count,
3690 request_bytes,
3691 rendered,
3692 ),
3693 Err(code) => (code, 0, 0, 0, String::new()),
3694 }
3695}
3696
3697fn is_impact_statusline(cli: &Cli) -> bool {
3700 matches!(
3701 cli.command.as_ref(),
3702 Some(Command::Impact {
3703 subcommand: Some(ImpactCli::Statusline),
3704 all: false,
3705 ..
3706 })
3707 )
3708}
3709
3710fn dispatch_and_finalize(
3714 dispatch: &DispatchContext<'_>,
3715 command: Option<Command>,
3716) -> Result<ExitCode, ExitCode> {
3717 let cli = dispatch.cli;
3718 let output = dispatch.output;
3719 let quiet = dispatch.quiet;
3720
3721 if let Some(path) = cli.output_file.as_deref()
3724 && let Err(code) = redirect_report_to_file(path, output)
3725 {
3726 return Err(code);
3727 }
3728
3729 let exit_code = if command.is_some() && cli_has_bare_coverage_input(cli) {
3730 emit_error(bare_coverage_subcommand_error_message(), 2, output)
3731 } else if command.is_some()
3732 && let Some(flag) = cli_bare_combined_baseline_flag(cli)
3733 {
3734 emit_error(
3735 &bare_combined_baseline_subcommand_error_message(flag),
3736 2,
3737 output,
3738 )
3739 } else {
3740 match command {
3741 None => dispatch_bare_command(dispatch),
3742 Some(cmd) => dispatch_subcommand(cmd, dispatch),
3743 }
3744 };
3745
3746 if let Some(path) = cli.output_file.as_deref()
3747 && let Err(code) = finalize_report_file(path, quiet, output)
3748 {
3749 return Err(code);
3750 }
3751 Ok(exit_code)
3752}
3753
3754fn run_telemetry_command_if_requested(
3755 cli: &mut Cli,
3756 output: fallow_config::OutputFormat,
3757 json_style: json_style::JsonStyle,
3758) -> Option<ExitCode> {
3759 if matches!(cli.command, Some(Command::Telemetry { .. }))
3760 && let Some(Command::Telemetry { subcommand }) = cli.command.take()
3761 {
3762 return Some(telemetry::run(
3763 map_telemetry_subcommand(subcommand),
3764 output,
3765 json_style,
3766 ));
3767 }
3768 None
3769}
3770
3771fn run_doctor_command_if_requested(cli: &Cli, format: &FormatConfig) -> Option<ExitCode> {
3776 if !matches!(cli.command, Some(Command::Doctor)) {
3777 return None;
3778 }
3779
3780 if let Some(flag) = unsupported_doctor_option(cli) {
3781 let message = if flag == "--output-file" {
3782 "--output-file is not valid with `fallow doctor`; doctor is read-only and writes its report to stdout".to_string()
3783 } else {
3784 format!("{flag} is not valid with `fallow doctor`.")
3785 };
3786 return Some(crate::error::emit_error_with_style(
3787 &message,
3788 2,
3789 format.output,
3790 format.json_style,
3791 ));
3792 }
3793 if let Err(code) = doctor::validate_output(format.output, format.json_style) {
3794 return Some(code);
3795 }
3796
3797 let root = cli.root.clone().unwrap_or_else(|| {
3798 std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
3799 });
3800 let config_path = cli.config.as_ref().map(|path| {
3801 if path_util::is_absolute_path_any_platform(path) {
3802 path.clone()
3803 } else {
3804 root.join(path)
3805 }
3806 });
3807 let report = doctor::collect_report(&root, config_path.as_deref());
3808 Some(doctor::render_report(
3809 &report,
3810 format.output,
3811 format.json_style,
3812 ))
3813}
3814
3815fn unsupported_doctor_option(cli: &Cli) -> Option<&'static str> {
3819 [
3820 (cli.allow_remote_extends, "--allow-remote-extends"),
3821 (cli.no_cache, "--no-cache"),
3822 (cli.threads.is_some(), "--threads"),
3823 (cli.changed_since.is_some(), "--changed-since"),
3824 (cli.diff_file.is_some(), "--diff-file"),
3825 (cli.diff_stdin, "--diff-stdin"),
3826 (cli.churn_file.is_some(), "--churn-file"),
3827 (cli.max_file_size.is_some(), "--max-file-size"),
3828 (cli.baseline.is_some(), "--baseline"),
3829 (cli.baseline_mode.is_some(), "--baseline-mode"),
3830 (cli.parent_run.is_some(), "--parent-run"),
3831 (cli.save_baseline.is_some(), "--save-baseline"),
3832 (cli.production, "--production"),
3833 (cli.no_production, "--no-production"),
3834 (cli.no_package_baselines, "--no-package-baselines"),
3835 (cli.production_dead_code, "--production-dead-code"),
3836 (cli.production_health, "--production-health"),
3837 (cli.production_dupes, "--production-dupes"),
3838 (cli.workspace.is_some(), "--workspace"),
3839 (cli.changed_workspaces.is_some(), "--changed-workspaces"),
3840 (cli.group_by.is_some(), "--group-by"),
3841 (cli.performance, "--performance"),
3842 (cli.explain, "--explain"),
3843 (cli.explain_skipped, "--explain-skipped"),
3844 (cli.summary, "--summary"),
3845 (cli.ci, "--ci"),
3846 (cli.fail_on_issues, "--fail-on-issues"),
3847 (cli.sarif_file.is_some(), "--sarif-file"),
3848 (cli.output_file.is_some(), "--output-file"),
3849 (cli.report_path_prefix.is_some(), "--report-path-prefix"),
3850 (cli.fail_on_regression, "--fail-on-regression"),
3851 (cli.fail_on_stale_baseline, "--fail-on-stale-baseline"),
3852 (cli.fail_on_baseline_growth, "--fail-on-baseline-growth"),
3853 (cli.baseline_base.is_some(), "--baseline-base"),
3854 (cli.fail_on_parse_error, "--fail-on-parse-error"),
3855 (cli.tolerance != "0", "--tolerance"),
3856 (cli.regression_baseline.is_some(), "--regression-baseline"),
3857 (
3858 cli.save_regression_baseline.is_some(),
3859 "--save-regression-baseline",
3860 ),
3861 (!cli.only.is_empty(), "--only"),
3862 (!cli.skip.is_empty(), "--skip"),
3863 (cli.dupes_mode.is_some(), "--dupes-mode"),
3864 (cli.dupes_near, "--dupes-near"),
3865 (cli.dupes_threshold.is_some(), "--dupes-threshold"),
3866 (cli.dupes_min_tokens.is_some(), "--dupes-min-tokens"),
3867 (cli.dupes_min_lines.is_some(), "--dupes-min-lines"),
3868 (
3869 cli.dupes_min_occurrences.is_some(),
3870 "--dupes-min-occurrences",
3871 ),
3872 (cli.dupes_skip_local, "--dupes-skip-local"),
3873 (cli.dupes_cross_language, "--dupes-cross-language"),
3874 (cli.dupes_ignore_imports, "--dupes-ignore-imports"),
3875 (cli.dupes_no_ignore_imports, "--dupes-no-ignore-imports"),
3876 (cli.score, "--score"),
3877 (cli.trend, "--trend"),
3878 (cli.save_snapshot.is_some(), "--save-snapshot"),
3879 (cli.coverage.is_some(), "--coverage"),
3880 (cli.coverage_root.is_some(), "--coverage-root"),
3881 (cli.dupes_baseline.is_some(), "--dupes-baseline"),
3882 (cli.health_baseline.is_some(), "--health-baseline"),
3883 (cli.include_entry_exports, "--include-entry-exports"),
3884 (cli.type_aware, "--type-aware"),
3885 (cli.no_type_aware, "--no-type-aware"),
3886 (!cli.type_aware_project.is_empty(), "--type-aware-project"),
3887 (cli.type_aware_require.is_some(), "--type-aware-require"),
3888 ]
3889 .into_iter()
3890 .find_map(|(used, flag)| used.then_some(flag))
3891}
3892
3893fn run_schema_command_if_requested(
3894 cli: &Cli,
3895 json_style: json_style::JsonStyle,
3896) -> Option<ExitCode> {
3897 match cli.command {
3898 Some(Command::Schema) => Some(schema::run_schema(json_style)),
3899 Some(Command::ConfigSchema) => Some(init::run_config_schema(json_style)),
3900 Some(Command::PluginSchema) => Some(init::run_plugin_schema(json_style)),
3901 Some(Command::RulePackSchema) => Some(init::run_rule_pack_schema(json_style)),
3902 _ => None,
3903 }
3904}
3905
3906fn regression_save_targets(cli: &Cli) -> (Option<std::path::PathBuf>, bool) {
3907 let save_file = cli.save_regression_baseline.as_ref().and_then(|opt| {
3908 opt.as_ref()
3909 .filter(|path| !path.is_empty())
3910 .map(std::path::PathBuf::from)
3911 });
3912 let save_to_config = cli.save_regression_baseline.is_some() && save_file.is_none();
3913 (save_file, save_to_config)
3914}
3915
3916fn dispatch_bare_command(dispatch: &DispatchContext<'_>) -> ExitCode {
3917 let cli = dispatch.cli;
3918 let (run_check, run_dupes, run_health) = combined::resolve_analyses(&cli.only, &cli.skip);
3919 let production = match dispatch.production_modes(
3920 cli.production_dead_code,
3921 cli.production_health,
3922 cli.production_dupes,
3923 ) {
3924 Ok(production) => production,
3925 Err(code) => return code,
3926 };
3927 let coverage_inputs = if run_health {
3932 match resolve_health_coverage_inputs(
3933 dispatch,
3934 cli.coverage.as_deref(),
3935 cli.coverage_root.as_deref(),
3936 ) {
3937 Ok(inputs) => inputs,
3938 Err(code) => return code,
3939 }
3940 } else {
3941 ResolvedHealthCoverageInputs::default()
3942 };
3943 run_bare_combined(
3944 dispatch,
3945 production,
3946 &coverage_inputs,
3947 BareAnalyses {
3948 run_check,
3949 run_dupes,
3950 run_health,
3951 },
3952 )
3953}
3954
3955#[derive(Clone, Copy)]
3957struct BareAnalyses {
3958 run_check: bool,
3959 run_dupes: bool,
3960 run_health: bool,
3961}
3962
3963fn run_bare_combined(
3966 dispatch: &DispatchContext<'_>,
3967 production: ProductionModes,
3968 coverage_inputs: &ResolvedHealthCoverageInputs,
3969 analyses: BareAnalyses,
3970) -> ExitCode {
3971 let cli = dispatch.cli;
3972 let output = dispatch.output;
3973 if cli.fail_on_parse_error && !analyses.run_check && !analyses.run_health {
3974 return error::emit_error_with_style(
3975 "--fail-on-parse-error needs the dead-code or health analysis, and this run analyzes neither. Include dead-code or health in --only or --skip, or remove the flag.",
3976 2,
3977 output,
3978 dispatch.json_style,
3979 );
3980 }
3981 let scope = match crate::scope_path::resolve_command_scope(
3982 dispatch.root,
3983 dispatch.output,
3984 cli.path.clone(),
3985 ) {
3986 Ok(scope) => scope.map(|resolved| resolved.absolute),
3987 Err(code) => return code,
3988 };
3989 let growth_targets = baseline_growth::targets(&[
3990 (
3991 cli.baseline.as_deref().filter(|_| analyses.run_check),
3992 fallow_engine::baseline::BaselineKind::DeadCode,
3993 ),
3994 (
3995 cli.dupes_baseline.as_deref().filter(|_| analyses.run_dupes),
3996 fallow_engine::baseline::BaselineKind::Dupes,
3997 ),
3998 (
3999 cli.health_baseline
4000 .as_deref()
4001 .filter(|_| analyses.run_health),
4002 fallow_engine::baseline::BaselineKind::Health,
4003 ),
4004 ]);
4005 baseline_growth::run_gated(
4006 &dispatch.growth_ctx(baseline_growth::GrowthOwner::Combined),
4007 &growth_targets,
4008 || run_combined_scoped(dispatch, production, coverage_inputs, analyses, scope),
4009 )
4010}
4011
4012fn run_combined_scoped(
4013 dispatch: &DispatchContext<'_>,
4014 production: ProductionModes,
4015 coverage_inputs: &ResolvedHealthCoverageInputs,
4016 analyses: BareAnalyses,
4017 scope: Option<PathBuf>,
4018) -> ExitCode {
4019 let cli = dispatch.cli;
4020 let (output, quiet, fail_on_issues) =
4021 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
4022 let scoped_run = scope.is_some();
4023 combined::run_combined(&combined::CombinedOptions {
4024 root: dispatch.root,
4025 config_path: &cli.config,
4026 output,
4027 json_style: dispatch.json_style,
4028 no_cache: cli.no_cache,
4029 threads: dispatch.threads,
4030 quiet,
4031 allow_remote_extends: cli.allow_remote_extends,
4032 fail_on_issues,
4033 sarif_file: cli.sarif_file.as_deref(),
4034 changed_since: cli.changed_since.as_deref(),
4035 no_package_baselines: cli.no_package_baselines,
4036 churn_file: cli.churn_file.as_deref(),
4037 baseline: cli.baseline.as_deref(),
4038 save_baseline: cli.save_baseline.as_deref(),
4039 dupes_baseline: cli.dupes_baseline.as_deref(),
4040 health_baseline: cli.health_baseline.as_deref(),
4041 health_baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
4042 health_baseline_mode_explicit: cli.baseline_mode.is_some(),
4043 fail_on_stale_baseline: cli.fail_on_stale_baseline,
4044 production: cli.production,
4045 production_dead_code: Some(production.dead_code),
4046 production_health: Some(production.health),
4047 production_dupes: Some(production.dupes),
4048 workspace: cli.workspace.as_deref(),
4049 changed_workspaces: cli.changed_workspaces.as_deref(),
4050 group_by: cli.group_by,
4051 type_aware: cli.type_aware_override(),
4052 type_aware_projects: &cli.type_aware_project,
4053 type_aware_require: cli.type_aware_require.map(Into::into),
4054 explain: cli.explain,
4055 explain_skipped: cli.explain_skipped,
4056 performance: cli.performance,
4057 summary: cli.summary,
4058 run_check: analyses.run_check,
4059 run_dupes: analyses.run_dupes,
4060 run_health: analyses.run_health,
4061 dupes_mode: cli.dupes_mode,
4062 dupes_near: cli.dupes_near,
4063 dupes_threshold: cli.dupes_threshold,
4064 dupes_min_tokens: cli.dupes_min_tokens,
4065 dupes_min_lines: cli.dupes_min_lines,
4066 dupes_min_occurrences: cli.dupes_min_occurrences,
4067 dupes_skip_local: cli.dupes_skip_local,
4068 dupes_cross_language: cli.dupes_cross_language,
4069 dupes_ignore_imports: resolve_ignore_imports(
4070 cli.dupes_ignore_imports,
4071 cli.dupes_no_ignore_imports,
4072 ),
4073 score: cli.score || cli.trend,
4074 trend: cli.trend,
4075 save_snapshot: cli.save_snapshot.as_ref(),
4076 coverage: coverage_inputs.coverage.as_deref(),
4077 coverage_root: coverage_inputs.coverage_root.as_deref(),
4078 include_entry_exports: cli.include_entry_exports,
4079 fail_on_parse_error: cli.fail_on_parse_error,
4080 scope,
4081 regression_opts: dispatch.regression_opts(
4082 cli.changed_since.is_some()
4083 || cli.workspace.is_some()
4084 || cli.changed_workspaces.is_some()
4085 || scoped_run,
4086 ),
4087 })
4088}
4089
4090#[allow(
4091 clippy::too_many_lines,
4092 reason = "the command router is intentionally an exhaustive top-level dispatch table"
4093)]
4094fn dispatch_subcommand(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4095 let cli = dispatch.cli;
4096 let root = dispatch.root;
4097 let output = dispatch.output;
4098 let quiet = dispatch.quiet;
4099 match command {
4100 check @ Command::Check { .. } => dispatch_check_command(check, dispatch),
4101 Command::Watch { no_clear } => dispatch_watch(dispatch, no_clear),
4102 Command::TypeAware { subcommand } => dispatch_type_aware_command(dispatch, subcommand),
4103 Command::Doctor => unreachable!("doctor bypasses the normal dispatch epilogue"),
4104 Command::SimilarCode {
4105 subcommand,
4106 threshold,
4107 min_lines,
4108 top,
4109 file,
4110 path,
4111 } => {
4112 let scope = match crate::scope_path::resolve_command_scope(
4113 dispatch.root,
4114 dispatch.output,
4115 path,
4116 ) {
4117 Ok(scope) => scope,
4118 Err(code) => return code,
4119 };
4120 similar_code_cli::run(similar_code_cli::SimilarCodeCliInput {
4121 root,
4122 config_path: cli.config.as_deref(),
4123 allow_remote_extends: cli.allow_remote_extends,
4124 no_cache: cli.no_cache,
4125 threads: dispatch.threads,
4126 changed_since: cli.changed_since.as_deref(),
4127 diff_file: cli.diff_file.as_deref(),
4128 workspace: cli.workspace.as_deref(),
4129 changed_workspaces: cli.changed_workspaces.as_deref(),
4130 explain: cli.explain,
4131 quiet,
4132 output,
4133 json_style: dispatch.json_style,
4134 threshold,
4135 min_lines,
4136 top,
4137 files: file,
4138 scope,
4139 subcommand,
4140 })
4141 }
4142 Command::Inspect {
4143 file,
4144 symbol,
4145 symbol_chain,
4146 churn,
4147 } => dispatch_inspect_command(dispatch, file, symbol, symbol_chain, churn),
4148 Command::Trace {
4149 symbol,
4150 path,
4151 eager_only,
4152 callers,
4153 callees,
4154 depth,
4155 } => dispatch_trace_command(
4156 dispatch,
4157 symbol,
4158 &path,
4159 eager_only,
4160 TraceChainFlags {
4161 callers,
4162 callees,
4163 depth,
4164 },
4165 ),
4166 Command::TraceError { trace_file } => {
4167 trace_error::run_trace_error(&trace_error::TraceErrorOptions {
4168 root: dispatch.root,
4169 config_path: &dispatch.cli.config,
4170 output: dispatch.output,
4171 json_style: dispatch.json_style,
4172 no_cache: dispatch.cli.no_cache,
4173 threads: dispatch.threads,
4174 quiet: dispatch.quiet,
4175 allow_remote_extends: dispatch.cli.allow_remote_extends,
4176 trace_file: trace_file.as_deref(),
4177 })
4178 }
4179 fix @ Command::Fix { .. } => dispatch_fix_command(&fix, dispatch),
4180 init @ Command::Init { .. } => dispatch_init_command(init, root, quiet),
4181 Command::Hooks { subcommand } => {
4182 run_hooks_command(root, subcommand, output, dispatch.json_style)
4183 }
4184 Command::Agent { subcommand } => dispatch_agent_command(dispatch, subcommand),
4185 Command::Ci { subcommand } => {
4186 ci::run(map_ci_subcommand(subcommand), output, dispatch.json_style)
4187 }
4188 Command::ConfigSchema => init::run_config_schema(dispatch.json_style),
4189 Command::PluginSchema => init::run_plugin_schema(dispatch.json_style),
4190 Command::PluginCheck => plugin_check::run_plugin_check(root, output, dispatch.json_style),
4191 Command::RulePackSchema => init::run_rule_pack_schema(dispatch.json_style),
4192 Command::RulePack { subcommand } => dispatch_rule_pack_command(dispatch, subcommand),
4193 Command::Guard { files } => dispatch_guard_command(dispatch, &files),
4194 Command::CiTemplate { subcommand } => dispatch_ci_template_command(subcommand),
4195 Command::Config { path } => config::run_config_with_options(config::RunConfigInput {
4196 root,
4197 explicit_config: cli.config.as_deref(),
4198 path_only: path,
4199 output,
4200 quiet,
4201 json_style: dispatch.json_style,
4202 load_options: fallow_config::ConfigLoadOptions {
4203 allow_remote_extends: cli.allow_remote_extends,
4204 },
4205 }),
4206 Command::Recommend => onboarding::run_recommend(root, output, dispatch.json_style),
4207 list @ (Command::Workspaces | Command::List { .. }) => {
4208 dispatch_list_command(&list, dispatch)
4209 }
4210 dupes @ Command::Dupes { .. } => dispatch_dupes_command(dupes, dispatch),
4211 health @ Command::Health { .. } => dispatch_health_command(health, dispatch),
4212 Command::Flags {
4213 top,
4214 retirement,
4215 reasons,
4216 sort,
4217 flag_age,
4218 min_age,
4219 flag_state,
4220 max_flag_age,
4221 } => dispatch_flags_command(
4222 dispatch,
4223 top,
4224 retirement.then_some(flags::RetirementArgs {
4225 reasons,
4226 sort,
4227 flag_age,
4228 min_age,
4229 flag_state,
4230 max_flag_age,
4231 }),
4232 ),
4233 Command::Suppressions { file } => dispatch_suppressions_command(dispatch, &file),
4234 Command::Explain { issue_type } => {
4235 explain::run_explain(&issue_type.join(" "), output, dispatch.json_style)
4236 }
4237 audit @ Command::Audit { .. } => dispatch_audit_command(audit, dispatch),
4238 Command::AuditCache { subcommand } => dispatch_audit_cache_command(dispatch, &subcommand),
4239 Command::DecisionSurface { max_decisions } => {
4240 dispatch_decision_surface(dispatch, max_decisions)
4241 }
4242 Command::Impact {
4243 subcommand,
4244 all,
4245 sort,
4246 limit,
4247 } => dispatch_impact(
4248 root,
4249 quiet,
4250 output,
4251 dispatch.json_style,
4252 subcommand,
4253 ImpactCrossRepoOpts { all, sort, limit },
4254 ),
4255 security @ Command::Security { .. } => dispatch_security_command(security, dispatch),
4256 Command::Viz {
4257 output: viz_output,
4258 no_open,
4259 viz_format,
4260 } => dispatch_viz(dispatch, viz_output.as_deref(), no_open, viz_format),
4261 Command::Report { from } => {
4262 cli_report::run_report(&from, output, root, cli.config.as_deref(), cli.quiet)
4263 }
4264 Command::Schema => unreachable!("handled above"),
4265 migrate @ Command::Migrate { .. } => dispatch_migrate_command(migrate, root),
4266 Command::License { subcommand } => {
4267 dispatch_license_command(subcommand, output, dispatch.json_style)
4268 }
4269 Command::Telemetry { .. } => unreachable!("handled before root validation"),
4270 Command::Coverage { subcommand } => dispatch_coverage_command(dispatch, &subcommand),
4271 setup_hooks @ Command::SetupHooks { .. } => {
4272 dispatch_setup_hooks_command(&setup_hooks, dispatch)
4273 }
4274 }
4275}
4276
4277fn dispatch_type_aware_command(
4278 dispatch: &DispatchContext<'_>,
4279 subcommand: TypeAwareCli,
4280) -> ExitCode {
4281 match subcommand {
4282 TypeAwareCli::Status => {
4283 let status = fallow_api::type_aware_status(dispatch.root);
4284 match dispatch.output {
4285 fallow_config::OutputFormat::Json => {
4286 let output = type_aware_status_output(dispatch.root, status);
4287 match fallow_output::serialize_type_aware_status_json_output(output) {
4288 Ok(value) => match dispatch.json_style.serialize(&value) {
4289 Ok(json) => {
4290 crate::report::sink::outln!("{json}");
4291 ExitCode::SUCCESS
4292 }
4293 Err(error) => emit_error(
4294 &format!("failed to serialize type-aware status: {error}"),
4295 2,
4296 dispatch.output,
4297 ),
4298 },
4299 Err(error) => emit_error(
4300 &format!("failed to build type-aware status: {error}"),
4301 2,
4302 dispatch.output,
4303 ),
4304 }
4305 }
4306 fallow_config::OutputFormat::Human => {
4307 if status.available {
4308 crate::report::sink::outln!(
4309 "{}",
4310 report::human_status_line(
4311 report::HumanStatus::Ok,
4312 format_args!(
4313 "Type-aware companion: available ({}, protocol {}, TypeScript {})",
4314 status.package_version.as_deref().unwrap_or("unknown"),
4315 status.protocol_version,
4316 status.backend_version.as_deref().unwrap_or("unknown"),
4317 )
4318 )
4319 );
4320 } else {
4321 crate::report::sink::outln!(
4322 "{}",
4323 report::human_status_line(
4324 report::HumanStatus::Inactive,
4325 "Type-aware companion: unavailable"
4326 )
4327 );
4328 if let Some(remediation) = status.remediation {
4329 crate::report::sink::outln!(
4330 "{}",
4331 report::human_status_line(
4332 report::HumanStatus::Warning,
4333 format_args!("Action: {remediation}")
4334 )
4335 );
4336 }
4337 }
4338 ExitCode::SUCCESS
4339 }
4340 _ => emit_error(
4341 "type-aware status supports human and json output",
4342 2,
4343 dispatch.output,
4344 ),
4345 }
4346 }
4347 }
4348}
4349
4350fn type_aware_status_output(
4351 root: &Path,
4352 status: fallow_api::TypeAwareStatus,
4353) -> fallow_output::TypeAwareStatusOutput {
4354 let companion_path = status.companion_path.as_deref().map(|path| {
4355 if let Ok(relative) = path.strip_prefix(root)
4356 && !relative.as_os_str().is_empty()
4357 {
4358 relative.to_string_lossy().replace('\\', "/")
4359 } else {
4360 path.file_name()
4361 .unwrap_or(path.as_os_str())
4362 .to_string_lossy()
4363 .into_owned()
4364 }
4365 });
4366 let remediation = status.remediation.map(|message| {
4367 let without_root = message.replace(root.to_string_lossy().as_ref(), ".");
4368 status.companion_path.as_deref().map_or_else(
4369 || without_root.clone(),
4370 |path| {
4371 without_root.replace(
4372 path.to_string_lossy().as_ref(),
4373 companion_path.as_deref().unwrap_or("fallow-type-aware"),
4374 )
4375 },
4376 )
4377 });
4378 fallow_output::TypeAwareStatusOutput {
4379 schema_version: fallow_types::envelope::SchemaVersion(
4380 fallow_output::TYPE_AWARE_STATUS_SCHEMA_VERSION,
4381 ),
4382 version: fallow_types::envelope::ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
4383 available: status.available,
4384 discovery_source: status.discovery_source.map(str::to_string),
4385 companion_path,
4386 package_version: status.package_version,
4387 protocol_version: status.protocol_version,
4388 backend_family: status.backend_family,
4389 backend_version: status.backend_version,
4390 remediation,
4391 }
4392}
4393
4394fn dispatch_check_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4396 let filters = check_issue_filters(&command);
4397 let Command::Check {
4398 include_dupes,
4399 trace,
4400 trace_file,
4401 trace_dependency,
4402 impact_closure,
4403 symbol_impact,
4404 top,
4405 file,
4406 finding_id,
4407 path,
4408 ..
4409 } = command
4410 else {
4411 unreachable!("check dispatcher only handles check commands");
4412 };
4413
4414 let finding_ids = match fallow_engine::dead_code::FindingIdFilter::parse(&finding_id) {
4415 Ok(filter) => filter,
4416 Err(message) => return emit_error(&format!("--finding-id: {message}"), 2, dispatch.output),
4417 };
4418
4419 let scope = match crate::scope_path::resolve_command_scope(dispatch.root, dispatch.output, path)
4420 {
4421 Ok(scope) => scope.map(|resolved| resolved.absolute),
4422 Err(code) => return code,
4423 };
4424
4425 dispatch_check(
4426 dispatch,
4427 &CheckDispatchArgs {
4428 filters,
4429 trace_opts: TraceOptions {
4430 trace_export: trace,
4431 trace_file,
4432 trace_dependency,
4433 impact_closure,
4434 symbol_impact,
4435 performance: dispatch.cli.performance,
4436 },
4437 include_dupes,
4438 type_aware: dispatch.cli.type_aware_override(),
4439 type_aware_project: dispatch.cli.type_aware_project.clone(),
4440 type_aware_require: dispatch.cli.type_aware_require,
4441 top,
4442 file,
4443 scope,
4444 finding_ids,
4445 },
4446 )
4447}
4448
4449fn check_issue_filters(command: &Command) -> IssueFilters {
4454 check_issue_filters_framework(command, &check_issue_filters_core(command))
4455}
4456
4457fn check_issue_filters_core(command: &Command) -> IssueFilters {
4460 let Command::Check {
4461 unused_files,
4462 unused_exports,
4463 unused_deps,
4464 unused_types,
4465 private_type_leaks,
4466 deprecated_exports_in_use,
4467 unused_enum_members,
4468 unused_class_members,
4469 unresolved_imports,
4470 unlisted_deps,
4471 duplicate_exports,
4472 circular_deps,
4473 re_export_cycles,
4474 package_cycles,
4475 boundary_violations,
4476 policy_violations,
4477 stale_suppressions,
4478 ..
4479 } = command
4480 else {
4481 unreachable!("check filter builder only handles check commands");
4482 };
4483
4484 let mut filters = IssueFilters::default();
4485 for (flag, active) in [
4486 ("--unused-files", *unused_files),
4487 ("--unused-exports", *unused_exports),
4488 ("--unused-deps", *unused_deps),
4489 ("--unused-types", *unused_types),
4490 ("--private-type-leaks", *private_type_leaks),
4491 ("--deprecated-exports-in-use", *deprecated_exports_in_use),
4492 ("--unused-enum-members", *unused_enum_members),
4493 ("--unused-class-members", *unused_class_members),
4494 ("--unresolved-imports", *unresolved_imports),
4495 ("--unlisted-deps", *unlisted_deps),
4496 ("--duplicate-exports", *duplicate_exports),
4497 ("--circular-deps", *circular_deps),
4498 ("--re-export-cycles", *re_export_cycles),
4499 ("--package-cycles", *package_cycles),
4500 ("--boundary-violations", *boundary_violations),
4501 ("--policy-violations", *policy_violations),
4502 ("--stale-suppressions", *stale_suppressions),
4503 ] {
4504 enable_check_filter(&mut filters, flag, active);
4505 }
4506 filters
4507}
4508
4509fn check_issue_filters_framework(command: &Command, base: &IssueFilters) -> IssueFilters {
4512 let Command::Check {
4513 unused_store_members,
4514 unprovided_injects,
4515 unrendered_components,
4516 unused_component_props,
4517 unused_component_emits,
4518 unused_component_inputs,
4519 unused_component_outputs,
4520 unused_svelte_events,
4521 unused_server_actions,
4522 unused_load_data_keys,
4523 unused_catalog_entries,
4524 empty_catalog_groups,
4525 unresolved_catalog_references,
4526 unused_dependency_overrides,
4527 misconfigured_dependency_overrides,
4528 ..
4529 } = command
4530 else {
4531 unreachable!("check filter builder only handles check commands");
4532 };
4533
4534 let mut filters = base.clone();
4535 for (flag, active) in [
4536 ("--unused-store-members", *unused_store_members),
4537 ("--unprovided-injects", *unprovided_injects),
4538 ("--unrendered-components", *unrendered_components),
4539 ("--unused-component-props", *unused_component_props),
4540 ("--unused-component-emits", *unused_component_emits),
4541 ("--unused-component-inputs", *unused_component_inputs),
4542 ("--unused-component-outputs", *unused_component_outputs),
4543 ("--unused-svelte-events", *unused_svelte_events),
4544 ("--unused-server-actions", *unused_server_actions),
4545 ("--unused-load-data-keys", *unused_load_data_keys),
4546 ("--unused-catalog-entries", *unused_catalog_entries),
4547 ("--empty-catalog-groups", *empty_catalog_groups),
4548 (
4549 "--unresolved-catalog-references",
4550 *unresolved_catalog_references,
4551 ),
4552 (
4553 "--unused-dependency-overrides",
4554 *unused_dependency_overrides,
4555 ),
4556 (
4557 "--misconfigured-dependency-overrides",
4558 *misconfigured_dependency_overrides,
4559 ),
4560 ] {
4561 enable_check_filter(&mut filters, flag, active);
4562 }
4563 filters
4564}
4565
4566fn enable_check_filter(filters: &mut IssueFilters, flag: &str, active: bool) {
4567 if active {
4568 assert!(
4569 filters.enable_cli_filter_flag(flag),
4570 "check command uses unregistered dead-code filter flag {flag}"
4571 );
4572 }
4573}
4574
4575fn dispatch_inspect_command(
4576 dispatch: &DispatchContext<'_>,
4577 file: Option<String>,
4578 symbol: Option<String>,
4579 symbol_chain: bool,
4580 churn: bool,
4581) -> ExitCode {
4582 let target = match (file, symbol) {
4583 (Some(file), None) => inspect::InspectTarget::File { file },
4584 (None, Some(symbol)) => match selector::parse_file_symbol_selector(&symbol) {
4585 Some((file, export_name)) => inspect::InspectTarget::Symbol {
4586 file: file.to_string(),
4587 export_name: export_name.to_string(),
4588 },
4589 None => {
4590 return emit_error(
4591 "--symbol must be formatted as FILE:EXPORT",
4592 2,
4593 dispatch.output,
4594 );
4595 }
4596 },
4597 _ => {
4598 return emit_error(
4599 "inspect requires exactly one of --file or --symbol",
4600 2,
4601 dispatch.output,
4602 );
4603 }
4604 };
4605
4606 let churn_config = if churn {
4607 match load_config_for_analysis(
4608 dispatch.root,
4609 &dispatch.cli.config,
4610 ConfigLoadOptions {
4611 output: dispatch.output,
4612 no_cache: dispatch.cli.no_cache,
4613 threads: dispatch.threads,
4614 production_override: None,
4615 quiet: dispatch.quiet,
4616 allow_remote_extends: dispatch.cli.allow_remote_extends,
4617 },
4618 fallow_config::ProductionAnalysis::Health,
4619 ) {
4620 Ok(config) => Some(config),
4621 Err(code) => return code,
4622 }
4623 } else {
4624 None
4625 };
4626
4627 inspect::run_inspect(&inspect::InspectOptions {
4628 root: dispatch.root,
4629 config_path: dispatch.cli.config.as_ref(),
4630 output: dispatch.output,
4631 json_style: dispatch.json_style,
4632 no_cache: dispatch.cli.no_cache,
4633 no_production: dispatch.cli.no_production,
4634 max_file_size: dispatch.cli.max_file_size,
4635 threads: dispatch.threads,
4636 quiet: dispatch.quiet,
4637 production: dispatch.cli.production,
4638 workspace: dispatch.cli.workspace.as_ref(),
4639 target,
4640 churn_cache_dir: churn_config
4641 .as_ref()
4642 .map(|config| config.cache_dir.as_path()),
4643 symbol_chain,
4644 type_aware: dispatch.cli.type_aware_override(),
4645 type_aware_projects: &dispatch.cli.type_aware_project,
4646 type_aware_require: dispatch.cli.type_aware_require.map(Into::into),
4647 })
4648}
4649
4650#[derive(Clone, Copy)]
4652struct TraceChainFlags {
4653 callers: bool,
4654 callees: bool,
4655 depth: Option<u32>,
4656}
4657
4658fn dispatch_trace_command(
4659 dispatch: &DispatchContext<'_>,
4660 symbol: Option<String>,
4661 path: &[String],
4662 eager_only: bool,
4663 chain: TraceChainFlags,
4664) -> ExitCode {
4665 let TraceChainFlags {
4666 callers,
4667 callees,
4668 depth,
4669 } = chain;
4670 if let [from, to] = path {
4671 return trace_path::run_trace_path(&trace_path::TracePathOptions {
4672 root: dispatch.root,
4673 config_path: &dispatch.cli.config,
4674 output: dispatch.output,
4675 json_style: dispatch.json_style,
4676 no_cache: dispatch.cli.no_cache,
4677 threads: dispatch.threads,
4678 quiet: dispatch.quiet,
4679 allow_remote_extends: dispatch.cli.allow_remote_extends,
4680 from,
4681 to,
4682 eager_only,
4683 });
4684 }
4685 let Some(symbol) = symbol else {
4686 return emit_error(
4687 "trace requires a FILE:SYMBOL target or --path <FROM> <TO>",
4688 2,
4689 dispatch.output,
4690 );
4691 };
4692 trace_chain::run_trace(&trace_chain::TraceChainOptions {
4693 root: dispatch.root,
4694 config_path: &dispatch.cli.config,
4695 output: dispatch.output,
4696 json_style: dispatch.json_style,
4697 no_cache: dispatch.cli.no_cache,
4698 threads: dispatch.threads,
4699 quiet: dispatch.quiet,
4700 allow_remote_extends: dispatch.cli.allow_remote_extends,
4701 target: symbol,
4702 callers,
4703 callees,
4704 depth: depth.unwrap_or(fallow_types::trace_chain::DEFAULT_TRACE_DEPTH),
4705 })
4706}
4707
4708fn dispatch_security_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4709 let Command::Security {
4710 subcommand,
4711 runtime_coverage,
4712 min_invocations_hot,
4713 file,
4714 gate,
4715 surface,
4716 path,
4717 } = command
4718 else {
4719 unreachable!("security dispatcher only handles security commands");
4720 };
4721
4722 let scope = match crate::scope_path::resolve_command_scope(dispatch.root, dispatch.output, path)
4723 {
4724 Ok(scope) => scope.map(|resolved| resolved.absolute),
4725 Err(code) => return code,
4726 };
4727
4728 let gate = gate.map(security::SecurityGateArg::into_mode);
4729 let cli = dispatch.cli;
4730 let (output, _quiet, fail_on_issues) =
4731 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
4732 let derived_flags = SecurityDerivedFlagState {
4733 output,
4734 json_style: dispatch.json_style,
4735 ci: cli.ci,
4736 fail_on_issues,
4737 sarif_file: cli.sarif_file.as_deref(),
4738 summary: cli.summary,
4739 explain: cli.explain,
4740 runtime_coverage: runtime_coverage.as_deref(),
4741 min_invocations_hot,
4742 file: file.as_slice(),
4743 gate,
4744 surface,
4745 };
4746 if let Some(code) = try_run_security_survivors(subcommand.as_ref(), &derived_flags) {
4747 return code;
4748 }
4749
4750 let scoped_files = scoped_security_files(&file, subcommand.as_ref());
4751 run_security_blind_spots_or_default(
4752 dispatch,
4753 &SecurityRunInputs {
4754 scoped_files: &scoped_files,
4755 subcommand: &subcommand,
4756 runtime_coverage: runtime_coverage.as_deref(),
4757 min_invocations_hot,
4758 gate,
4759 surface,
4760 scope,
4761 },
4762 &derived_flags,
4763 )
4764}
4765
4766struct SecurityRunInputs<'a> {
4769 scoped_files: &'a [PathBuf],
4770 subcommand: &'a Option<SecuritySubcommand>,
4771 runtime_coverage: Option<&'a Path>,
4772 min_invocations_hot: u64,
4773 gate: Option<security::SecurityGateMode>,
4774 surface: bool,
4775 scope: Option<PathBuf>,
4776}
4777
4778fn run_security_blind_spots_or_default(
4780 dispatch: &DispatchContext<'_>,
4781 inputs: &SecurityRunInputs<'_>,
4782 derived_flags: &SecurityDerivedFlagState<'_>,
4783) -> ExitCode {
4784 let cli = dispatch.cli;
4785 let (output, quiet, fail_on_issues) =
4786 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
4787 let opts = security::SecurityOptions {
4788 root: dispatch.root,
4789 config_path: &cli.config,
4790 output,
4791 json_style: dispatch.json_style,
4792 no_cache: cli.no_cache,
4793 threads: dispatch.threads,
4794 quiet,
4795 allow_remote_extends: cli.allow_remote_extends,
4796 fail_on_issues,
4797 sarif_file: cli.sarif_file.as_deref(),
4798 summary: cli.summary,
4799 changed_since: cli.changed_since.as_deref(),
4800 use_shared_diff_index: true,
4801 workspace: cli.workspace.as_deref(),
4802 changed_workspaces: cli.changed_workspaces.as_deref(),
4803 file: inputs.scoped_files,
4804 surface: inputs.surface,
4805 scope: inputs.scope.clone(),
4806 gate: inputs.gate,
4807 runtime_coverage: inputs.runtime_coverage,
4808 min_invocations_hot: inputs.min_invocations_hot,
4809 explain: cli.explain,
4810 };
4811 if matches!(
4812 inputs.subcommand,
4813 Some(SecuritySubcommand::BlindSpots { .. })
4814 ) {
4815 if let Some(code) = validate_security_blind_spots_flags(derived_flags) {
4816 return code;
4817 }
4818 security::run_blind_spots(&opts)
4819 } else {
4820 security::run(&opts)
4821 }
4822}
4823
4824fn try_run_security_survivors(
4827 subcommand: Option<&SecuritySubcommand>,
4828 flags: &SecurityDerivedFlagState<'_>,
4829) -> Option<ExitCode> {
4830 let Some(SecuritySubcommand::Survivors {
4831 candidates,
4832 verdicts,
4833 require_verdict_for_each_candidate,
4834 }) = subcommand
4835 else {
4836 return None;
4837 };
4838 if let Some(code) = validate_security_survivors_flags(flags) {
4839 return Some(code);
4840 }
4841 Some(security::run_survivors(
4842 &security::SecuritySurvivorsOptions {
4843 output: flags.output,
4844 json_style: flags.json_style,
4845 candidates,
4846 verdicts,
4847 require_verdict_for_each_candidate: *require_verdict_for_each_candidate,
4848 },
4849 ))
4850}
4851
4852fn scoped_security_files(
4854 file: &[PathBuf],
4855 subcommand: Option<&SecuritySubcommand>,
4856) -> Vec<PathBuf> {
4857 let mut scoped_files = file.to_vec();
4858 if let Some(SecuritySubcommand::BlindSpots {
4859 file: blind_spot_files,
4860 }) = subcommand
4861 {
4862 scoped_files.extend(blind_spot_files.iter().cloned());
4863 }
4864 scoped_files
4865}
4866
4867struct SecurityDerivedFlagState<'a> {
4868 output: fallow_config::OutputFormat,
4869 json_style: json_style::JsonStyle,
4870 ci: bool,
4871 fail_on_issues: bool,
4872 sarif_file: Option<&'a Path>,
4873 summary: bool,
4874 explain: bool,
4875 runtime_coverage: Option<&'a Path>,
4876 min_invocations_hot: u64,
4877 file: &'a [PathBuf],
4878 gate: Option<security::SecurityGateMode>,
4879 surface: bool,
4880}
4881
4882fn validate_security_survivors_flags(flags: &SecurityDerivedFlagState<'_>) -> Option<ExitCode> {
4883 let flag = if flags.ci {
4884 Some("--ci")
4885 } else if flags.fail_on_issues {
4886 Some("--fail-on-issues")
4887 } else if flags.sarif_file.is_some() {
4888 Some("--sarif-file")
4889 } else if flags.summary {
4890 Some("--summary")
4891 } else if flags.explain {
4892 Some("--explain")
4893 } else if flags.runtime_coverage.is_some() {
4894 Some("--runtime-coverage")
4895 } else if flags.min_invocations_hot != DEFAULT_MIN_INVOCATIONS_HOT {
4896 Some("--min-invocations-hot")
4897 } else if !flags.file.is_empty() {
4898 Some("--file")
4899 } else if flags.gate.is_some() {
4900 Some("--gate")
4901 } else if flags.surface {
4902 Some("--surface")
4903 } else {
4904 None
4905 }?;
4906 Some(emit_error(
4907 &format!("{flag} is not valid with `fallow security survivors`."),
4908 2,
4909 flags.output,
4910 ))
4911}
4912
4913fn validate_security_blind_spots_flags(flags: &SecurityDerivedFlagState<'_>) -> Option<ExitCode> {
4914 let flag = if flags.ci {
4915 Some("--ci")
4916 } else if flags.fail_on_issues {
4917 Some("--fail-on-issues")
4918 } else if flags.sarif_file.is_some() {
4919 Some("--sarif-file")
4920 } else if flags.summary {
4921 Some("--summary")
4922 } else if flags.explain {
4923 Some("--explain")
4924 } else if flags.runtime_coverage.is_some() {
4925 Some("--runtime-coverage")
4926 } else if flags.min_invocations_hot != DEFAULT_MIN_INVOCATIONS_HOT {
4927 Some("--min-invocations-hot")
4928 } else if flags.gate.is_some() {
4929 Some("--gate")
4930 } else if flags.surface {
4931 Some("--surface")
4932 } else {
4933 None
4934 }?;
4935 Some(emit_error(
4936 &format!("{flag} is not valid with `fallow security blind-spots`."),
4937 2,
4938 flags.output,
4939 ))
4940}
4941
4942fn dispatch_dupes_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4943 let Command::Dupes {
4944 mode,
4945 near,
4946 min_tokens,
4947 min_lines,
4948 min_occurrences,
4949 threshold,
4950 skip_local,
4951 cross_language,
4952 ignore_imports,
4953 no_ignore_imports,
4954 top,
4955 no_fragments,
4956 trace,
4957 path,
4958 } = command
4959 else {
4960 unreachable!("dupes dispatcher only handles dupes commands");
4961 };
4962
4963 let scope = match crate::scope_path::resolve_command_scope(dispatch.root, dispatch.output, path)
4964 {
4965 Ok(scope) => scope.map(|resolved| resolved.absolute),
4966 Err(code) => return code,
4967 };
4968
4969 dispatch_dupes(
4970 dispatch,
4971 &DupesDispatchArgs {
4972 mode,
4973 near,
4974 min_tokens,
4975 min_lines,
4976 min_occurrences,
4977 threshold,
4978 skip_local,
4979 cross_language,
4980 ignore_imports,
4981 no_ignore_imports,
4982 top,
4983 no_fragments,
4984 trace,
4985 scope,
4986 },
4987 )
4988}
4989
4990fn dispatch_agent_command(dispatch: &DispatchContext<'_>, subcommand: AgentCli) -> ExitCode {
4991 run_agent_command(
4992 dispatch.root,
4993 dispatch.cli.root.is_some(),
4994 subcommand,
4995 dispatch.output,
4996 dispatch.json_style,
4997 )
4998}
4999
5000fn dispatch_init_command(command: Command, root: &Path, quiet: bool) -> ExitCode {
5001 let Command::Init {
5002 toml,
5003 agents,
5004 hooks,
5005 branch,
5006 decline,
5007 } = command
5008 else {
5009 unreachable!("init dispatcher only handles init commands");
5010 };
5011
5012 init::run_init(&init::InitOptions {
5013 root,
5014 use_toml: toml,
5015 agents,
5016 hooks,
5017 branch: branch.as_deref(),
5018 decline,
5019 quiet,
5020 })
5021}
5022
5023fn dispatch_fix_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
5024 let Command::Fix {
5025 dry_run,
5026 yes,
5027 no_create_config,
5028 path,
5029 } = command
5030 else {
5031 unreachable!("fix dispatcher only handles fix commands");
5032 };
5033
5034 let scope = match crate::scope_path::resolve_command_scope(
5035 dispatch.root,
5036 dispatch.output,
5037 path.clone(),
5038 ) {
5039 Ok(scope) => scope.map(|resolved| resolved.absolute),
5040 Err(code) => return code,
5041 };
5042
5043 dispatch_fix(
5044 dispatch,
5045 &FixDispatchArgs {
5046 dry_run: *dry_run,
5047 yes: *yes,
5048 no_create_config: *no_create_config,
5049 scope,
5050 },
5051 )
5052}
5053
5054fn dispatch_list_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
5055 match command {
5056 Command::Workspaces => dispatch_list(dispatch, &ListDispatchArgs::workspaces()),
5057 Command::List {
5058 entry_points,
5059 files,
5060 plugins,
5061 boundaries,
5062 workspaces,
5063 entry_weight,
5064 path,
5065 } => {
5066 let scope = match crate::scope_path::resolve_command_scope(
5067 dispatch.root,
5068 dispatch.output,
5069 path.clone(),
5070 ) {
5071 Ok(scope) => scope.map(|resolved| resolved.absolute),
5072 Err(code) => return code,
5073 };
5074 dispatch_list(
5075 dispatch,
5076 &ListDispatchArgs {
5077 entry_points: *entry_points,
5078 files: *files,
5079 plugins: *plugins,
5080 boundaries: *boundaries,
5081 workspaces: *workspaces,
5082 entry_weight: *entry_weight,
5083 scope,
5084 },
5085 )
5086 }
5087 _ => unreachable!("list dispatcher only handles list commands"),
5088 }
5089}
5090
5091fn dispatch_migrate_command(command: Command, root: &Path) -> ExitCode {
5092 let Command::Migrate {
5093 toml,
5094 jsonc,
5095 dry_run,
5096 from,
5097 } = command
5098 else {
5099 unreachable!("migrate dispatcher only handles migrate commands");
5100 };
5101
5102 migrate::run_migrate(root, toml, jsonc, dry_run, from.as_deref())
5103}
5104
5105fn dispatch_license_command(
5106 subcommand: LicenseCli,
5107 output: fallow_config::OutputFormat,
5108 json_style: json_style::JsonStyle,
5109) -> ExitCode {
5110 license::run(&map_license_subcommand(subcommand), output, json_style)
5111}
5112
5113fn dispatch_ci_template_command(subcommand: CiTemplateCli) -> ExitCode {
5114 match subcommand {
5115 CiTemplateCli::Gitlab { vendor, force } => {
5116 ci_template::run_gitlab_template(&ci_template::GitlabTemplateOptions {
5117 vendor_dir: vendor,
5118 force,
5119 })
5120 }
5121 }
5122}
5123
5124fn dispatch_coverage_command(dispatch: &DispatchContext<'_>, subcommand: &CoverageCli) -> ExitCode {
5125 let cli = dispatch.cli;
5126 coverage::run(
5127 map_coverage_subcommand(subcommand, cli.explain, cli.changed_since.as_deref()),
5128 &coverage::RunContext {
5129 root: dispatch.root,
5130 config_path: &cli.config,
5131 output: dispatch.output,
5132 json_style: dispatch.json_style,
5133 quiet: dispatch.quiet,
5134 no_cache: cli.no_cache,
5135 threads: dispatch.threads,
5136 explain: cli.explain,
5137 allow_remote_extends: cli.allow_remote_extends,
5138 },
5139 )
5140}
5141
5142fn dispatch_health_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
5143 let Command::Health {
5144 max_cyclomatic,
5145 max_cognitive,
5146 max_crap,
5147 top,
5148 sort,
5149 complexity,
5150 complexity_breakdown,
5151 file_scores,
5152 coverage_gaps,
5153 hotspots,
5154 ownership,
5155 ownership_emails,
5156 targets,
5157 type_coupling,
5158 css,
5159 effort,
5160 score,
5161 min_score,
5162 min_severity,
5163 report_only,
5164 since,
5165 min_commits,
5166 save_snapshot,
5167 trend,
5168 coverage,
5169 coverage_root,
5170 runtime_coverage,
5171 min_invocations_hot,
5172 min_observation_volume,
5173 low_traffic_threshold,
5174 path,
5175 } = command
5176 else {
5177 unreachable!("health dispatcher only handles health commands");
5178 };
5179
5180 let scope = match crate::scope_path::resolve_command_scope(dispatch.root, dispatch.output, path)
5181 {
5182 Ok(scope) => scope.map(|resolved| resolved.absolute),
5183 Err(code) => return code,
5184 };
5185
5186 let ownership = ownership || ownership_emails.is_some();
5187 let hotspots = hotspots || ownership;
5188 let args = HealthDispatchArgs {
5189 max_cyclomatic,
5190 max_cognitive,
5191 max_crap,
5192 top,
5193 sort,
5194 complexity,
5195 complexity_breakdown,
5196 file_scores,
5197 coverage_gaps,
5198 hotspots,
5199 ownership,
5200 ownership_emails: ownership_emails.map(EmailModeArg::to_config),
5201 targets,
5202 type_coupling,
5203 css,
5204 effort,
5205 score,
5206 min_score,
5207 min_severity: min_severity.map(HealthSeverityCli::to_health_severity),
5208 report_only,
5209 since: since.as_deref(),
5210 min_commits,
5211 save_snapshot: save_snapshot.as_ref(),
5212 trend,
5213 fail_on_stale_baseline: dispatch.cli.fail_on_stale_baseline,
5214 fail_on_parse_error: dispatch.cli.fail_on_parse_error,
5215 coverage: coverage.as_deref(),
5216 coverage_root: coverage_root.as_deref(),
5217 runtime_coverage: runtime_coverage.as_deref(),
5218 min_invocations_hot,
5219 min_observation_volume,
5220 low_traffic_threshold,
5221 scope,
5222 };
5223 dispatch_health(dispatch, &args)
5224}
5225
5226fn dispatch_setup_hooks_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
5227 let Command::SetupHooks {
5228 agent,
5229 dry_run,
5230 force,
5231 user,
5232 gitignore_claude,
5233 uninstall,
5234 } = command
5235 else {
5236 unreachable!("setup-hooks dispatcher only handles setup-hooks commands");
5237 };
5238
5239 eprintln!(
5240 "warning: `fallow setup-hooks` is deprecated and will be removed in the next major; use `fallow agent install` or `fallow hooks install --target agent`."
5241 );
5242 setup_hooks::run_setup_hooks(&setup_hooks::SetupHooksOptions {
5243 root: dispatch.root,
5244 agent: *agent,
5245 dry_run: *dry_run,
5246 force: *force,
5247 user: *user,
5248 gitignore_claude: *gitignore_claude,
5249 uninstall: *uninstall,
5250 })
5251}
5252
5253fn dispatch_audit_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
5254 let Command::Audit {
5255 production_dead_code,
5256 production_health,
5257 production_dupes,
5258 dead_code_baseline,
5259 health_baseline,
5260 dupes_baseline,
5261 max_crap,
5262 coverage,
5263 coverage_root,
5264 no_css,
5265 css_deep,
5266 no_css_deep,
5267 gate,
5268 runtime_coverage,
5269 min_invocations_hot,
5270 gate_marker,
5271 brief,
5272 max_decisions,
5273 walkthrough_guide,
5274 walkthrough_file,
5275 walkthrough,
5276 mark_viewed,
5277 show_cleared,
5278 show_deprioritized,
5279 path,
5280 } = command
5281 else {
5282 unreachable!("audit dispatcher only handles audit commands");
5283 };
5284
5285 let brief = brief || walkthrough_guide || walkthrough || walkthrough_file.is_some();
5288
5289 let scope = match crate::scope_path::resolve_command_scope(dispatch.root, dispatch.output, path)
5290 {
5291 Ok(scope) => scope.map(|resolved| resolved.absolute),
5292 Err(code) => return code,
5293 };
5294
5295 dispatch_audit(
5296 dispatch,
5297 &AuditDispatchArgs {
5298 production_dead_code,
5299 production_health,
5300 production_dupes,
5301 dead_code_baseline,
5302 health_baseline,
5303 dupes_baseline,
5304 max_crap,
5305 coverage,
5306 coverage_root,
5307 no_css,
5308 css_deep,
5309 no_css_deep,
5310 gate,
5311 runtime_coverage,
5312 min_invocations_hot,
5313 gate_marker,
5314 brief,
5315 max_decisions,
5316 walkthrough_guide,
5317 walkthrough_file,
5318 walkthrough,
5319 mark_viewed,
5320 show_cleared,
5321 show_deprioritized,
5322 scope,
5323 },
5324 )
5325}
5326
5327fn dispatch_audit_cache_command(
5328 dispatch: &DispatchContext<'_>,
5329 subcommand: &AuditCacheCli,
5330) -> ExitCode {
5331 match subcommand {
5332 AuditCacheCli::Remove { dry_run, yes } => {
5333 if !*dry_run && !*yes && !std::io::stdin().is_terminal() {
5334 return emit_error(
5335 "audit-cache remove requires --yes (or --force) in non-interactive environments. Use --dry-run to preview removal first, then pass --yes to confirm.",
5336 2,
5337 dispatch.output,
5338 );
5339 }
5340 match base_worktree::remove_reusable_audit_caches(dispatch.root, *dry_run) {
5341 Ok(report) => {
5342 let action = if *dry_run { "would remove" } else { "removed" };
5343 if matches!(dispatch.output, fallow_config::OutputFormat::Json) {
5344 let value = serde_json::json!({
5345 "kind": "audit-cache-remove",
5346 "schema_version": 1,
5347 "command": "audit-cache remove",
5348 "root": dispatch.root,
5349 "dry_run": report.dry_run,
5350 "found": report.found,
5351 "would_remove": report.found.saturating_sub(report.skipped),
5352 "removed": report.removed,
5353 "skipped": report.skipped,
5354 "complete": report.skipped == 0,
5355 });
5356 let output_code = report::emit_report_json(
5357 &value,
5358 "audit cache removal",
5359 dispatch.json_style,
5360 );
5361 if output_code != ExitCode::SUCCESS {
5362 return output_code;
5363 }
5364 } else if !dispatch.quiet {
5365 println!(
5366 "audit cache: {action} {}, skipped {} for {}",
5367 if *dry_run {
5368 report.found.saturating_sub(report.skipped)
5369 } else {
5370 report.removed
5371 },
5372 report.skipped,
5373 dispatch.root.display(),
5374 );
5375 }
5376 if report.skipped == 0 {
5377 ExitCode::SUCCESS
5378 } else {
5379 ExitCode::from(2)
5380 }
5381 }
5382 Err(error) => emit_error(
5383 &format!(
5384 "failed to remove audit caches for {}: {error}",
5385 dispatch.root.display()
5386 ),
5387 2,
5388 dispatch.output,
5389 ),
5390 }
5391 }
5392 AuditCacheCli::Prune {
5393 dry_run,
5394 max_age_days,
5395 } => audit_cache_prune::run_audit_cache_prune(&audit_cache_prune::AuditCachePruneOptions {
5396 root: dispatch.root,
5397 config_path: dispatch.cli.config.as_ref(),
5398 allow_remote_extends: dispatch.cli.allow_remote_extends,
5399 dry_run: *dry_run,
5400 max_age_days: *max_age_days,
5401 output: dispatch.output,
5402 json_style: dispatch.json_style,
5403 quiet: dispatch.quiet,
5404 }),
5405 }
5406}
5407
5408fn dispatch_flags_command(
5409 dispatch: &DispatchContext<'_>,
5410 top: Option<usize>,
5411 retirement: Option<flags::RetirementArgs>,
5412) -> ExitCode {
5413 let cli = dispatch.cli;
5414 let root = dispatch.root;
5415 let output = dispatch.output;
5416 let quiet = dispatch.quiet;
5417 let threads = dispatch.threads;
5418 let production = match resolve_production_modes(cli, root, output, false, false, false) {
5419 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::DeadCode),
5420 Err(code) => return code,
5421 };
5422 flags::run_flags(&flags::FlagsOptions {
5423 root,
5424 config_path: &cli.config,
5425 output,
5426 json_style: dispatch.json_style,
5427 no_cache: cli.no_cache,
5428 threads,
5429 quiet,
5430 allow_remote_extends: cli.allow_remote_extends,
5431 production,
5432 workspace: cli.workspace.as_deref(),
5433 changed_workspaces: cli.changed_workspaces.as_deref(),
5434 changed_since: cli.changed_since.as_deref(),
5435 explain: cli.explain,
5436 top,
5437 retirement,
5438 regression: dispatch.regression_opts(false),
5439 regression_flag: first_regression_flag(cli),
5440 })
5441}
5442
5443fn first_regression_flag(cli: &Cli) -> Option<&'static str> {
5445 [
5446 (cli.fail_on_regression, "--fail-on-regression"),
5447 (cli.regression_baseline.is_some(), "--regression-baseline"),
5448 (
5449 cli.save_regression_baseline.is_some(),
5450 "--save-regression-baseline",
5451 ),
5452 (cli.tolerance != "0", "--tolerance"),
5453 ]
5454 .into_iter()
5455 .find_map(|(used, flag)| used.then_some(flag))
5456}
5457
5458fn dispatch_suppressions_command(
5459 dispatch: &DispatchContext<'_>,
5460 file: &[std::path::PathBuf],
5461) -> ExitCode {
5462 let cli = dispatch.cli;
5463 let root = dispatch.root;
5464 let output = dispatch.output;
5465 let production = match resolve_production_modes(cli, root, output, false, false, false) {
5466 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::DeadCode),
5467 Err(code) => return code,
5468 };
5469 suppressions::run_suppressions(&suppressions::SuppressionsOptions {
5470 root,
5471 config_path: &cli.config,
5472 output,
5473 json_style: dispatch.json_style,
5474 no_cache: cli.no_cache,
5475 threads: dispatch.threads,
5476 quiet: dispatch.quiet,
5477 allow_remote_extends: cli.allow_remote_extends,
5478 production,
5479 workspace: cli.workspace.as_deref(),
5480 changed_workspaces: cli.changed_workspaces.as_deref(),
5481 changed_since: cli.changed_since.as_deref(),
5482 file,
5483 })
5484}
5485
5486fn dispatch_guard_command(dispatch: &DispatchContext<'_>, files: &[String]) -> ExitCode {
5487 guard::run_guard(&guard::GuardOptions {
5488 root: dispatch.root,
5489 config_path: &dispatch.cli.config,
5490 output: dispatch.output,
5491 json_style: dispatch.json_style,
5492 quiet: dispatch.quiet,
5493 allow_remote_extends: dispatch.cli.allow_remote_extends,
5494 files,
5495 })
5496}
5497
5498fn dispatch_rule_pack_command(dispatch: &DispatchContext<'_>, subcommand: RulePackCli) -> ExitCode {
5499 let ctx = rule_pack::RulePackContext {
5500 root: dispatch.root,
5501 config_path: &dispatch.cli.config,
5502 output: dispatch.output,
5503 json_style: dispatch.json_style,
5504 quiet: dispatch.quiet,
5505 no_cache: dispatch.cli.no_cache,
5506 threads: Some(dispatch.threads),
5507 allow_remote_extends: dispatch.cli.allow_remote_extends,
5508 };
5509 rule_pack::run(&map_rule_pack_subcommand(subcommand), &ctx)
5510}
5511
5512fn map_rule_pack_subcommand(subcommand: RulePackCli) -> rule_pack::RulePackSubcommand {
5513 match subcommand {
5514 RulePackCli::Init {
5515 name,
5516 template,
5517 dir,
5518 no_config,
5519 } => rule_pack::RulePackSubcommand::Init(rule_pack::InitArgs {
5520 name,
5521 template,
5522 dir,
5523 no_config,
5524 }),
5525 RulePackCli::List => rule_pack::RulePackSubcommand::List,
5526 RulePackCli::Test { pack } => {
5527 rule_pack::RulePackSubcommand::Test(rule_pack::TestArgs { pack })
5528 }
5529 RulePackCli::Schema => rule_pack::RulePackSubcommand::Schema,
5530 }
5531}
5532
5533fn map_license_subcommand(sub: LicenseCli) -> license::LicenseSubcommand {
5534 match sub {
5535 LicenseCli::Activate {
5536 jwt,
5537 from_file,
5538 stdin,
5539 trial,
5540 email,
5541 } => license::LicenseSubcommand::Activate(license::ActivateArgs {
5542 raw_jwt: jwt,
5543 from_file,
5544 from_stdin: stdin,
5545 trial,
5546 email,
5547 }),
5548 LicenseCli::Status => license::LicenseSubcommand::Status,
5549 LicenseCli::Refresh { api_key } => {
5550 license::LicenseSubcommand::Refresh(license::RefreshArgs { api_key })
5551 }
5552 LicenseCli::Deactivate => license::LicenseSubcommand::Deactivate,
5553 }
5554}
5555
5556fn map_telemetry_subcommand(sub: TelemetryCli) -> telemetry::TelemetryCommand {
5557 match sub {
5558 TelemetryCli::Status => telemetry::TelemetryCommand::Status,
5559 TelemetryCli::Enable => telemetry::TelemetryCommand::Enable,
5560 TelemetryCli::Disable => telemetry::TelemetryCommand::Disable,
5561 TelemetryCli::Inspect { example } => telemetry::TelemetryCommand::Inspect { example },
5562 }
5563}
5564
5565fn map_ci_subcommand(sub: CiCli) -> ci::CiCommand {
5566 match sub {
5567 command @ CiCli::PlanPrComment { .. } => map_ci_plan_pr_comment(command),
5568 command @ CiCli::PostPrComment { .. } => map_ci_post_pr_comment(command),
5569 command @ CiCli::PostReview { .. } => map_ci_post_review(command),
5570 command @ CiCli::PostCheckRun { .. } => map_ci_post_check_run(command),
5571 command @ CiCli::ReconcileReview { .. } => map_ci_reconcile_review(command),
5572 }
5573}
5574
5575fn map_ci_plan_pr_comment(command: CiCli) -> ci::CiCommand {
5576 let CiCli::PlanPrComment {
5577 body,
5578 marker_id,
5579 clean,
5580 existing_comment_id,
5581 existing_body,
5582 } = command
5583 else {
5584 unreachable!("ci plan-pr-comment mapper called with different variant");
5585 };
5586
5587 ci::CiCommand::PlanPrComment {
5588 body,
5589 marker_id,
5590 clean,
5591 existing_comment_id,
5592 existing_body,
5593 }
5594}
5595
5596fn map_ci_post_pr_comment(command: CiCli) -> ci::CiCommand {
5597 let CiCli::PostPrComment {
5598 provider,
5599 pr,
5600 mr,
5601 body,
5602 envelope,
5603 marker_id,
5604 clean,
5605 repo,
5606 project_id,
5607 api_url,
5608 dry_run,
5609 } = command
5610 else {
5611 unreachable!("ci post-pr-comment mapper called with different variant");
5612 };
5613
5614 ci::CiCommand::PostPrComment {
5615 provider: map_ci_provider(provider),
5616 target: pr.or(mr),
5617 body,
5618 envelope,
5619 marker_id,
5620 clean,
5621 repo,
5622 project_id,
5623 api_url,
5624 dry_run,
5625 }
5626}
5627
5628fn map_ci_post_review(command: CiCli) -> ci::CiCommand {
5629 let CiCli::PostReview {
5630 provider,
5631 pr,
5632 mr,
5633 envelope,
5634 repo,
5635 project_id,
5636 api_url,
5637 dry_run,
5638 } = command
5639 else {
5640 unreachable!("ci post-review mapper called with different variant");
5641 };
5642
5643 ci::CiCommand::PostReview {
5644 provider: map_ci_provider(provider),
5645 target: pr.or(mr),
5646 envelope,
5647 repo,
5648 project_id,
5649 api_url,
5650 dry_run,
5651 }
5652}
5653
5654fn map_ci_post_check_run(command: CiCli) -> ci::CiCommand {
5655 let CiCli::PostCheckRun {
5656 provider,
5657 decision,
5658 repo,
5659 head_sha,
5660 api_url,
5661 split_gates,
5662 dry_run,
5663 } = command
5664 else {
5665 unreachable!("ci post-check-run mapper called with different variant");
5666 };
5667
5668 ci::CiCommand::PostCheckRun {
5669 provider: map_ci_provider(provider),
5670 decision,
5671 repo,
5672 head_sha,
5673 api_url,
5674 split_gates,
5675 dry_run,
5676 }
5677}
5678
5679fn map_ci_reconcile_review(command: CiCli) -> ci::CiCommand {
5680 let CiCli::ReconcileReview {
5681 provider,
5682 pr,
5683 mr,
5684 envelope,
5685 repo,
5686 project_id,
5687 api_url,
5688 dry_run,
5689 } = command
5690 else {
5691 unreachable!("ci reconcile-review mapper called with different variant");
5692 };
5693
5694 ci::CiCommand::ReconcileReview {
5695 provider: map_ci_provider(provider),
5696 target: pr.or(mr),
5697 envelope,
5698 repo,
5699 project_id,
5700 api_url,
5701 dry_run,
5702 }
5703}
5704
5705fn map_ci_provider(provider: CiProviderArg) -> ci::CiProvider {
5706 match provider {
5707 CiProviderArg::Github => ci::CiProvider::Github,
5708 CiProviderArg::Gitlab => ci::CiProvider::Gitlab,
5709 }
5710}
5711
5712fn map_coverage_subcommand(
5713 sub: &CoverageCli,
5714 explain: bool,
5715 base: Option<&str>,
5716) -> coverage::CoverageSubcommand {
5717 match sub {
5718 CoverageCli::Setup {
5719 yes,
5720 non_interactive,
5721 json,
5722 } => map_coverage_setup(*yes, *non_interactive, *json, explain),
5723 CoverageCli::Analyze { .. } => map_coverage_analyze(sub),
5724 CoverageCli::UploadInventory { .. } => map_coverage_upload_inventory(sub),
5725 CoverageCli::UploadSourceMaps { .. } => map_coverage_upload_source_maps(sub),
5726 CoverageCli::UploadStaticFindings { .. } => map_coverage_upload_static_findings(sub),
5727 CoverageCli::ReviewPacket {
5728 api_key,
5729 api_endpoint,
5730 repo,
5731 project_id,
5732 coverage_period,
5733 commit_sha,
5734 files,
5735 functions,
5736 } => coverage::CoverageSubcommand::ReviewPacket(coverage::ReviewPacketArgs {
5737 api_key: api_key.clone(),
5738 api_endpoint: api_endpoint.clone(),
5739 repo: repo.clone(),
5740 project_id: project_id.clone(),
5741 coverage_period: *coverage_period,
5742 commit_sha: commit_sha.clone(),
5743 files: files.clone(),
5744 functions: functions.clone(),
5745 base: base.map(str::to_owned),
5746 }),
5747 CoverageCli::DeploymentChanges {
5748 api_key,
5749 api_endpoint,
5750 repo,
5751 sha,
5752 change,
5753 limit,
5754 cursor,
5755 } => coverage::CoverageSubcommand::DeploymentChanges(coverage::DeploymentChangesArgs {
5756 api_key: api_key.clone(),
5757 api_endpoint: api_endpoint.clone(),
5758 repo: repo.clone(),
5759 sha: sha.clone(),
5760 base: base.map(str::to_owned),
5761 change: change.clone(),
5762 limit: *limit,
5763 cursor: cursor.clone(),
5764 }),
5765 }
5766}
5767
5768fn map_coverage_setup(
5769 yes: bool,
5770 non_interactive: bool,
5771 json: bool,
5772 explain: bool,
5773) -> coverage::CoverageSubcommand {
5774 coverage::CoverageSubcommand::Setup(coverage::SetupArgs {
5775 yes,
5776 non_interactive: non_interactive || json,
5777 json,
5778 explain,
5779 })
5780}
5781
5782fn map_coverage_analyze(sub: &CoverageCli) -> coverage::CoverageSubcommand {
5783 let CoverageCli::Analyze {
5784 runtime_coverage,
5785 cloud,
5786 api_key,
5787 api_endpoint,
5788 repo,
5789 project_id,
5790 coverage_period,
5791 environment,
5792 commit_sha,
5793 production,
5794 min_invocations_hot,
5795 min_observation_volume,
5796 low_traffic_threshold,
5797 top,
5798 blast_radius,
5799 importance,
5800 debug_unmatched,
5801 } = sub
5802 else {
5803 unreachable!("coverage analyze mapper called with non-analyze variant");
5804 };
5805 coverage::CoverageSubcommand::Analyze(coverage::AnalyzeArgs {
5806 runtime_coverage: runtime_coverage.clone(),
5807 cloud: *cloud,
5808 api_key: api_key.clone(),
5809 api_endpoint: api_endpoint.clone(),
5810 repo: repo.clone(),
5811 project_id: project_id.clone(),
5812 coverage_period: *coverage_period,
5813 environment: environment.clone(),
5814 commit_sha: commit_sha.clone(),
5815 production: *production,
5816 min_invocations_hot: *min_invocations_hot,
5817 min_observation_volume: *min_observation_volume,
5818 low_traffic_threshold: *low_traffic_threshold,
5819 top: *top,
5820 blast_radius: *blast_radius,
5821 importance: *importance,
5822 debug_unmatched: *debug_unmatched,
5823 })
5824}
5825
5826fn map_coverage_upload_inventory(sub: &CoverageCli) -> coverage::CoverageSubcommand {
5827 let CoverageCli::UploadInventory {
5828 api_key,
5829 api_endpoint,
5830 project_id,
5831 git_sha,
5832 allow_dirty,
5833 exclude_paths,
5834 path_prefix,
5835 dry_run,
5836 with_callers,
5837 ignore_upload_errors,
5838 } = sub
5839 else {
5840 unreachable!("coverage inventory mapper called with non-inventory variant");
5841 };
5842 coverage::CoverageSubcommand::UploadInventory(coverage::UploadInventoryArgs {
5843 api_key: api_key.clone(),
5844 api_endpoint: api_endpoint.clone(),
5845 project_id: project_id.clone(),
5846 git_sha: git_sha.clone(),
5847 allow_dirty: *allow_dirty,
5848 exclude_paths: exclude_paths.clone(),
5849 path_prefix: path_prefix.clone(),
5850 dry_run: *dry_run,
5851 with_callers: *with_callers,
5852 ignore_upload_errors: *ignore_upload_errors,
5853 })
5854}
5855
5856fn map_coverage_upload_source_maps(sub: &CoverageCli) -> coverage::CoverageSubcommand {
5857 let CoverageCli::UploadSourceMaps {
5858 dir,
5859 include,
5860 exclude,
5861 repo,
5862 git_sha,
5863 endpoint,
5864 strip_path,
5865 dry_run,
5866 concurrency,
5867 fail_fast,
5868 } = sub
5869 else {
5870 unreachable!("coverage source-map mapper called with non-source-map variant");
5871 };
5872 coverage::CoverageSubcommand::UploadSourceMaps(coverage::UploadSourceMapsArgs {
5873 dir: dir.clone(),
5874 include: include.clone(),
5875 exclude: exclude.clone(),
5876 repo: repo.clone(),
5877 git_sha: git_sha.clone(),
5878 endpoint: endpoint.clone(),
5879 strip_path: *strip_path,
5880 dry_run: *dry_run,
5881 concurrency: *concurrency,
5882 fail_fast: *fail_fast,
5883 })
5884}
5885
5886fn map_coverage_upload_static_findings(sub: &CoverageCli) -> coverage::CoverageSubcommand {
5887 let CoverageCli::UploadStaticFindings {
5888 api_key,
5889 api_endpoint,
5890 project_id,
5891 git_sha,
5892 allow_dirty,
5893 dry_run,
5894 ignore_upload_errors,
5895 } = sub
5896 else {
5897 unreachable!("coverage static-findings mapper called with non-static variant");
5898 };
5899 coverage::CoverageSubcommand::UploadStaticFindings(coverage::UploadStaticFindingsArgs {
5900 api_key: api_key.clone(),
5901 api_endpoint: api_endpoint.clone(),
5902 project_id: project_id.clone(),
5903 git_sha: git_sha.clone(),
5904 allow_dirty: *allow_dirty,
5905 dry_run: *dry_run,
5906 ignore_upload_errors: *ignore_upload_errors,
5907 })
5908}
5909
5910struct CheckDispatchArgs {
5911 filters: IssueFilters,
5912 trace_opts: TraceOptions,
5913 include_dupes: bool,
5914 type_aware: Option<bool>,
5915 type_aware_project: Vec<std::path::PathBuf>,
5916 type_aware_require: Option<TypeAwareRequireArg>,
5917 top: Option<usize>,
5918 file: Vec<std::path::PathBuf>,
5919 scope: Option<std::path::PathBuf>,
5920 finding_ids: Option<fallow_engine::dead_code::FindingIdFilter>,
5921}
5922
5923#[derive(Clone)]
5924struct ListDispatchArgs {
5925 entry_points: bool,
5926 files: bool,
5927 plugins: bool,
5928 boundaries: bool,
5929 workspaces: bool,
5930 entry_weight: bool,
5931 scope: Option<std::path::PathBuf>,
5932}
5933
5934impl ListDispatchArgs {
5935 fn workspaces() -> Self {
5936 Self {
5937 entry_points: false,
5938 files: false,
5939 plugins: false,
5940 boundaries: false,
5941 workspaces: true,
5942 entry_weight: false,
5943 scope: None,
5944 }
5945 }
5946}
5947
5948fn dispatch_viz(
5949 dispatch: &DispatchContext<'_>,
5950 output_path: Option<&std::path::Path>,
5951 no_open: bool,
5952 format: viz::VizFormat,
5953) -> ExitCode {
5954 let cli = dispatch.cli;
5955 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
5956 Ok(production) => production,
5957 Err(code) => return code,
5958 };
5959 viz::run_viz(&viz::VizOptions {
5960 root: dispatch.root,
5961 config_path: &cli.config,
5962 no_cache: cli.no_cache,
5963 threads: dispatch.threads,
5964 quiet: dispatch.quiet,
5965 production,
5966 allow_remote_extends: cli.allow_remote_extends,
5967 output_path,
5968 no_open,
5969 format,
5970 })
5971}
5972
5973fn dispatch_watch(dispatch: &DispatchContext<'_>, no_clear: bool) -> ExitCode {
5974 let cli = dispatch.cli;
5975 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
5976 Ok(production) => production,
5977 Err(code) => return code,
5978 };
5979 watch::run_watch(&watch::WatchOptions {
5980 root: dispatch.root,
5981 config_path: &cli.config,
5982 output: dispatch.output,
5983 json_style: dispatch.json_style,
5984 no_cache: cli.no_cache,
5985 threads: dispatch.threads,
5986 quiet: dispatch.quiet,
5987 allow_remote_extends: cli.allow_remote_extends,
5988 production,
5989 clear_screen: !no_clear,
5990 explain: cli.explain,
5991 include_entry_exports: cli.include_entry_exports,
5992 type_aware: cli.type_aware_override(),
5993 type_aware_projects: &cli.type_aware_project,
5994 type_aware_require: cli.type_aware_require.map(Into::into),
5995 })
5996}
5997
5998struct FixDispatchArgs {
5999 dry_run: bool,
6000 yes: bool,
6001 no_create_config: bool,
6002 scope: Option<std::path::PathBuf>,
6003}
6004
6005fn dispatch_fix(dispatch: &DispatchContext<'_>, args: &FixDispatchArgs) -> ExitCode {
6006 let cli = dispatch.cli;
6007 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
6008 Ok(production) => production,
6009 Err(code) => return code,
6010 };
6011 fix::run_fix(&fix::FixOptions {
6012 root: dispatch.root,
6013 config_path: &cli.config,
6014 output: dispatch.output,
6015 json_style: dispatch.json_style,
6016 no_cache: cli.no_cache,
6017 threads: dispatch.threads,
6018 quiet: dispatch.quiet,
6019 emit_output: true,
6020 allow_remote_extends: cli.allow_remote_extends,
6021 dry_run: args.dry_run,
6022 yes: args.yes,
6023 production,
6024 no_create_config: args.no_create_config,
6025 type_aware: cli.type_aware_override(),
6026 type_aware_projects: &cli.type_aware_project,
6027 type_aware_require: cli.type_aware_require.map(Into::into),
6028 scope: args.scope.clone(),
6029 })
6030}
6031
6032fn dispatch_list(dispatch: &DispatchContext<'_>, args: &ListDispatchArgs) -> ExitCode {
6033 let cli = dispatch.cli;
6034 let (save_regression_file, save_to_config) = regression_save_targets(cli);
6035 let entry_weight_gate = if args.entry_weight {
6038 let tolerance = match regression::Tolerance::parse(&cli.tolerance) {
6039 Ok(tolerance) => tolerance,
6040 Err(message) => {
6041 return emit_error(
6042 &format!("invalid --tolerance: {message}"),
6043 2,
6044 dispatch.output,
6045 );
6046 }
6047 };
6048 Some(regression::EntryWeightGate {
6049 fail_on_regression: cli.fail_on_regression,
6050 tolerance,
6051 baseline_file: cli.regression_baseline.as_deref(),
6052 save_file: save_regression_file.as_deref(),
6053 save_to_config,
6054 })
6055 } else {
6056 None
6057 };
6058 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
6059 Ok(production) => production,
6060 Err(code) => return code,
6061 };
6062 list::run_list(&ListOptions {
6063 root: dispatch.root,
6064 config_path: &cli.config,
6065 output: dispatch.output,
6066 json_style: dispatch.json_style,
6067 threads: dispatch.threads,
6068 no_cache: cli.no_cache,
6069 entry_points: args.entry_points,
6070 files: args.files,
6071 plugins: args.plugins,
6072 boundaries: args.boundaries,
6073 workspaces: args.workspaces,
6074 entry_weight: args.entry_weight,
6075 entry_weight_gate,
6076 production,
6077 allow_remote_extends: cli.allow_remote_extends,
6078 scope: args.scope.clone(),
6079 })
6080}
6081
6082fn dispatch_check(dispatch: &DispatchContext<'_>, args: &CheckDispatchArgs) -> ExitCode {
6083 let cli = dispatch.cli;
6084 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
6085 Ok(production) => production,
6086 Err(code) => return code,
6087 };
6088 if let Some(code) = validate_type_aware_check_options(dispatch, args) {
6089 return code;
6090 }
6091 let growth_targets = baseline_growth::targets(&[(
6092 cli.baseline.as_deref(),
6093 fallow_engine::baseline::BaselineKind::DeadCode,
6094 )]);
6095 baseline_growth::run_gated(
6096 &dispatch.growth_ctx(baseline_growth::GrowthOwner::DeadCode),
6097 &growth_targets,
6098 || dispatch_check_run(dispatch, args, production),
6099 )
6100}
6101
6102fn dispatch_check_run(
6103 dispatch: &DispatchContext<'_>,
6104 args: &CheckDispatchArgs,
6105 production: bool,
6106) -> ExitCode {
6107 let cli = dispatch.cli;
6108 let (output, quiet, fail_on_issues) =
6109 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
6110 check::run_check(&CheckOptions {
6111 root: dispatch.root,
6112 config_path: &cli.config,
6113 output,
6114 json_style: dispatch.json_style,
6115 no_cache: cli.no_cache,
6116 threads: dispatch.threads,
6117 quiet,
6118 allow_remote_extends: cli.allow_remote_extends,
6119 fail_on_issues,
6120 filters: &args.filters,
6121 changed_since: cli.changed_since.as_deref(),
6122 change_scope_owner: fallow_engine::change_scope::ChangeScopeOwner::Run,
6123 no_package_baselines: cli.no_package_baselines,
6124 diff_index: None,
6125 use_shared_diff_index: true,
6126 baseline: cli.baseline.as_deref(),
6127 baseline_flag: "--baseline",
6128 save_baseline: cli.save_baseline.as_deref(),
6129 fail_on_stale_baseline: cli.fail_on_stale_baseline,
6130 sarif_file: cli.sarif_file.as_deref(),
6131 production,
6132 production_override: Some(production),
6133 workspace: cli.workspace.as_deref(),
6134 changed_workspaces: cli.changed_workspaces.as_deref(),
6135 group_by: cli.group_by,
6136 include_dupes: args.include_dupes,
6137 type_aware: args.type_aware,
6138 type_aware_config_override: None,
6139 type_aware_projects: &args.type_aware_project,
6140 type_aware_require: args.type_aware_require.map(Into::into),
6141 trace_opts: &args.trace_opts,
6142 explain: cli.explain,
6143 top: args.top,
6144 file: &args.file,
6145 scope: args.scope.clone(),
6146 finding_ids: args.finding_ids.as_ref(),
6147 include_entry_exports: cli.include_entry_exports,
6148 fail_on_parse_error: cli.fail_on_parse_error,
6149 summary: cli.summary,
6150 regression_opts: dispatch.regression_opts(
6151 cli.changed_since.is_some()
6152 || cli.workspace.is_some()
6153 || cli.changed_workspaces.is_some()
6154 || !args.file.is_empty()
6155 || args.scope.is_some(),
6156 ),
6157 retain_modules_for_health: false,
6158 defer_performance: true,
6159 analysis_snapshot: fallow_config::AnalysisSnapshot::Current,
6160 explain_skipped: cli.explain_skipped,
6161 })
6162}
6163
6164fn validate_type_aware_check_options(
6165 dispatch: &DispatchContext<'_>,
6166 args: &CheckDispatchArgs,
6167) -> Option<ExitCode> {
6168 let output = dispatch.output;
6169 if !args.type_aware_project.is_empty() && args.type_aware != Some(true) {
6170 return Some(emit_error(
6171 "--type-aware-project requires --type-aware",
6172 2,
6173 output,
6174 ));
6175 }
6176 if args.type_aware_require.is_some() && args.type_aware != Some(true) {
6177 return Some(emit_error(
6178 "--type-aware-require requires --type-aware",
6179 2,
6180 output,
6181 ));
6182 }
6183 if args.trace_opts.symbol_impact.is_some() && args.type_aware != Some(true) {
6184 return Some(emit_error(
6185 "--symbol-impact requires --type-aware",
6186 2,
6187 output,
6188 ));
6189 }
6190 let focused_output = args.trace_opts.trace_export.is_some()
6191 || args.trace_opts.trace_file.is_some()
6192 || args.trace_opts.trace_dependency.is_some()
6193 || args.trace_opts.impact_closure.is_some()
6194 || args.trace_opts.symbol_impact.is_some();
6195 if focused_output
6196 && !matches!(
6197 output,
6198 fallow_config::OutputFormat::Human | fallow_config::OutputFormat::Json
6199 )
6200 {
6201 return Some(emit_error(
6202 "focused trace and impact queries support human and JSON output",
6203 2,
6204 output,
6205 ));
6206 }
6207 if args.type_aware == Some(true)
6208 && !matches!(
6209 output,
6210 fallow_config::OutputFormat::Human
6211 | fallow_config::OutputFormat::Json
6212 | fallow_config::OutputFormat::Sarif
6213 | fallow_config::OutputFormat::Compact
6214 | fallow_config::OutputFormat::Markdown
6215 | fallow_config::OutputFormat::CodeClimate
6216 | fallow_config::OutputFormat::PrCommentGithub
6217 | fallow_config::OutputFormat::PrCommentGitlab
6218 | fallow_config::OutputFormat::ReviewGithub
6219 | fallow_config::OutputFormat::ReviewGitlab
6220 )
6221 {
6222 return Some(emit_error(
6223 "--type-aware supports human, JSON, SARIF, compact, markdown, CodeClimate, PR-comment, and review output; pair presentation formats with the JSON artifact to preserve semantic provenance",
6224 2,
6225 output,
6226 ));
6227 }
6228 None
6229}
6230
6231fn resolve_ignore_imports(ignore_imports: bool, no_ignore_imports: bool) -> Option<bool> {
6237 if no_ignore_imports {
6238 Some(false)
6239 } else if ignore_imports {
6240 Some(true)
6241 } else {
6242 None
6243 }
6244}
6245
6246struct DupesDispatchArgs {
6247 mode: Option<DupesMode>,
6248 near: bool,
6249 min_tokens: Option<usize>,
6250 min_lines: Option<usize>,
6251 min_occurrences: Option<usize>,
6252 threshold: Option<f64>,
6253 skip_local: bool,
6254 cross_language: bool,
6255 ignore_imports: bool,
6256 no_ignore_imports: bool,
6257 top: Option<usize>,
6258 no_fragments: bool,
6259 trace: Option<String>,
6260 scope: Option<std::path::PathBuf>,
6261}
6262
6263fn dispatch_dupes(dispatch: &DispatchContext<'_>, args: &DupesDispatchArgs) -> ExitCode {
6264 let cli = dispatch.cli;
6265 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::Dupes) {
6266 Ok(production) => production,
6267 Err(code) => return code,
6268 };
6269 let growth_targets = baseline_growth::targets(&[(
6270 cli.baseline.as_deref(),
6271 fallow_engine::baseline::BaselineKind::Dupes,
6272 )]);
6273 baseline_growth::run_gated(
6274 &dispatch.growth_ctx(baseline_growth::GrowthOwner::Dupes),
6275 &growth_targets,
6276 || dispatch_dupes_run(dispatch, args, production),
6277 )
6278}
6279
6280fn dispatch_dupes_run(
6281 dispatch: &DispatchContext<'_>,
6282 args: &DupesDispatchArgs,
6283 production: bool,
6284) -> ExitCode {
6285 let cli = dispatch.cli;
6286 let (output, quiet) = (dispatch.output, dispatch.quiet);
6287 dupes::run_dupes(&DupesOptions {
6288 root: dispatch.root,
6289 config_path: &cli.config,
6290 output,
6291 json_style: dispatch.json_style,
6292 no_cache: cli.no_cache,
6293 threads: dispatch.threads,
6294 quiet,
6295 allow_remote_extends: cli.allow_remote_extends,
6296 mode: args.mode,
6297 near: args.near,
6298 min_tokens: args.min_tokens,
6299 min_lines: args.min_lines,
6300 min_occurrences: args.min_occurrences,
6301 threshold: args.threshold,
6302 skip_local: args.skip_local,
6303 cross_language: args.cross_language,
6304 ignore_imports: resolve_ignore_imports(args.ignore_imports, args.no_ignore_imports),
6305 top: args.top,
6306 baseline_path: cli.baseline.as_deref(),
6307 baseline_flag: "--baseline",
6308 save_baseline_path: cli.save_baseline.as_deref(),
6309 fail_on_stale_baseline: cli.fail_on_stale_baseline,
6310 production,
6311 production_override: Some(production),
6312 trace: args.trace.as_deref(),
6313 changed_since: cli.changed_since.as_deref(),
6314 diff_index: None,
6315 use_shared_diff_index: true,
6316 changed_files: None,
6317 change_scope_owner: fallow_engine::change_scope::ChangeScopeOwner::Run,
6318 no_package_baselines: cli.no_package_baselines,
6319 workspace: cli.workspace.as_deref(),
6320 changed_workspaces: cli.changed_workspaces.as_deref(),
6321 explain: cli.explain,
6322 explain_skipped: cli.explain_skipped,
6323 summary: cli.summary,
6324 group_by: cli.group_by,
6325 performance: cli.performance,
6326 include_fragments: !args.no_fragments,
6327 retain_unfiltered_report: false,
6328 scope: args.scope.clone(),
6329 })
6330}
6331
6332struct AuditDispatchArgs {
6333 production_dead_code: bool,
6334 production_health: bool,
6335 production_dupes: bool,
6336 dead_code_baseline: Option<PathBuf>,
6337 health_baseline: Option<PathBuf>,
6338 dupes_baseline: Option<PathBuf>,
6339 max_crap: Option<f64>,
6340 coverage: Option<PathBuf>,
6341 coverage_root: Option<PathBuf>,
6342 no_css: bool,
6343 css_deep: bool,
6344 no_css_deep: bool,
6345 gate: Option<AuditGateArg>,
6346 runtime_coverage: Option<PathBuf>,
6347 min_invocations_hot: u64,
6348 gate_marker: Option<String>,
6349 brief: bool,
6350 max_decisions: usize,
6351 walkthrough_guide: bool,
6353 walkthrough_file: Option<PathBuf>,
6356 walkthrough: bool,
6358 mark_viewed: Vec<PathBuf>,
6360 show_cleared: bool,
6362 show_deprioritized: bool,
6364 scope: Option<PathBuf>,
6365}
6366
6367struct ResolvedAuditInputs {
6368 audit_cfg: fallow_config::AuditConfig,
6369 cache_dir: PathBuf,
6370 production: ProductionModes,
6371 dead_code_baseline: Option<PathBuf>,
6372 health_baseline: Option<PathBuf>,
6373 dupes_baseline: Option<PathBuf>,
6374 coverage: Option<PathBuf>,
6378 coverage_root: Option<PathBuf>,
6379}
6380
6381fn dispatch_audit(dispatch: &DispatchContext<'_>, args: &AuditDispatchArgs) -> ExitCode {
6382 let cli = dispatch.cli;
6383 let output = dispatch.output;
6384
6385 if cli.baseline.is_some() || cli.save_baseline.is_some() {
6386 return emit_error(
6387 "audit uses per-analysis baselines. Use --dead-code-baseline, --health-baseline, or --dupes-baseline (or save them with `fallow dead-code|health|dupes --save-baseline <file>`)",
6388 2,
6389 output,
6390 );
6391 }
6392
6393 let inputs = match resolve_audit_inputs(dispatch, args) {
6394 Ok(inputs) => inputs,
6395 Err(code) => return code,
6396 };
6397
6398 if cli.fail_on_baseline_growth && audit_renders_brief(args) {
6401 baseline_growth::note_stood_down(
6402 dispatch.growth_flags(),
6403 "the review brief never fails a run",
6404 );
6405 return run_resolved_audit(dispatch, args, &inputs);
6406 }
6407 let growth_targets = baseline_growth::targets(&[
6408 (
6409 inputs.dead_code_baseline.as_deref(),
6410 fallow_engine::baseline::BaselineKind::DeadCode,
6411 ),
6412 (
6413 inputs.health_baseline.as_deref(),
6414 fallow_engine::baseline::BaselineKind::Health,
6415 ),
6416 (
6417 inputs.dupes_baseline.as_deref(),
6418 fallow_engine::baseline::BaselineKind::Dupes,
6419 ),
6420 ]);
6421 baseline_growth::run_gated(
6422 &dispatch.growth_ctx(baseline_growth::GrowthOwner::Audit),
6423 &growth_targets,
6424 || run_resolved_audit(dispatch, args, &inputs),
6425 )
6426}
6427
6428const fn audit_renders_brief(args: &AuditDispatchArgs) -> bool {
6429 args.brief || args.walkthrough_guide || args.walkthrough || args.walkthrough_file.is_some()
6430}
6431
6432fn resolve_audit_inputs(
6433 dispatch: &DispatchContext<'_>,
6434 args: &AuditDispatchArgs,
6435) -> Result<ResolvedAuditInputs, ExitCode> {
6436 let cli = dispatch.cli;
6437 let root = dispatch.root;
6438 let output = dispatch.output;
6439 let config = load_config(
6440 root,
6441 &cli.config,
6442 LoadConfigArgs {
6443 output,
6444 no_cache: cli.no_cache,
6445 threads: dispatch.threads,
6446 production: cli.production,
6447 quiet: dispatch.quiet,
6448 allow_remote_extends: cli.allow_remote_extends,
6449 },
6450 )?;
6451 let cache_dir = config.cache_dir.clone();
6452 let audit_cfg = config.audit;
6453 let production = resolve_production_modes(
6454 cli,
6455 root,
6456 output,
6457 args.production_dead_code,
6458 args.production_health,
6459 args.production_dupes,
6460 )?;
6461 let resolved_dead_code_baseline = resolve_audit_baseline_path(
6462 root,
6463 args.dead_code_baseline.as_deref(),
6464 audit_cfg.dead_code_baseline.as_deref(),
6465 );
6466 let resolved_health_baseline = resolve_audit_baseline_path(
6467 root,
6468 args.health_baseline.as_deref(),
6469 audit_cfg.health_baseline.as_deref(),
6470 );
6471 let resolved_dupes_baseline = resolve_audit_baseline_path(
6472 root,
6473 args.dupes_baseline.as_deref(),
6474 audit_cfg.dupes_baseline.as_deref(),
6475 );
6476 let coverage_inputs = resolve_coverage_inputs(
6477 args.coverage.as_deref(),
6478 args.coverage_root.as_deref(),
6479 output,
6480 || Ok(config.health),
6481 )?;
6482
6483 Ok(ResolvedAuditInputs {
6484 audit_cfg,
6485 cache_dir,
6486 production,
6487 dead_code_baseline: resolved_dead_code_baseline,
6488 health_baseline: resolved_health_baseline,
6489 dupes_baseline: resolved_dupes_baseline,
6490 coverage: coverage_inputs.coverage,
6491 coverage_root: coverage_inputs.coverage_root,
6492 })
6493}
6494
6495fn audit_css_enabled(config: &fallow_config::AuditConfig, args: &AuditDispatchArgs) -> bool {
6496 !args.no_css && config.css.unwrap_or(true)
6497}
6498
6499fn audit_css_deep_enabled(config: &fallow_config::AuditConfig, args: &AuditDispatchArgs) -> bool {
6500 audit_css_enabled(config, args)
6501 && !args.no_css_deep
6502 && (args.css_deep || config.css_deep.unwrap_or(true))
6503}
6504
6505fn run_resolved_audit(
6506 dispatch: &DispatchContext<'_>,
6507 args: &AuditDispatchArgs,
6508 inputs: &ResolvedAuditInputs,
6509) -> ExitCode {
6510 let cli = dispatch.cli;
6511 audit::run_audit_with_type_aware(
6512 &audit::AuditOptions {
6513 root: dispatch.root,
6514 config_path: &cli.config,
6515 cache_dir: &inputs.cache_dir,
6516 output: dispatch.output,
6517 json_style: dispatch.json_style,
6518 no_cache: cli.no_cache,
6519 threads: dispatch.threads,
6520 quiet: dispatch.quiet,
6521 allow_remote_extends: cli.allow_remote_extends,
6522 changed_since: cli.changed_since.as_deref(),
6523 production: cli.production,
6524 production_dead_code: Some(inputs.production.dead_code),
6525 production_health: Some(inputs.production.health),
6526 production_dupes: Some(inputs.production.dupes),
6527 workspace: cli.workspace.as_deref(),
6528 changed_workspaces: cli.changed_workspaces.as_deref(),
6529 explain: cli.explain,
6530 explain_skipped: cli.explain_skipped,
6531 performance: cli.performance,
6532 group_by: cli.group_by,
6533 dead_code_baseline: inputs.dead_code_baseline.as_deref(),
6534 health_baseline: inputs.health_baseline.as_deref(),
6535 dupes_baseline: inputs.dupes_baseline.as_deref(),
6536 health_baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
6537 fail_on_stale_baseline: cli.fail_on_stale_baseline,
6538 max_crap: args.max_crap,
6539 coverage: inputs.coverage.as_deref(),
6540 coverage_root: inputs.coverage_root.as_deref(),
6541 gate: args.gate.map_or(inputs.audit_cfg.gate, Into::into),
6542 include_entry_exports: cli.include_entry_exports,
6543 fail_on_parse_error: cli.fail_on_parse_error,
6544 css: audit_css_enabled(&inputs.audit_cfg, args),
6548 css_deep: audit_css_deep_enabled(&inputs.audit_cfg, args),
6549 runtime_coverage: args.runtime_coverage.as_deref(),
6550 min_invocations_hot: args.min_invocations_hot,
6551 brief: args.brief,
6552 max_decisions: args.max_decisions,
6553 walkthrough_guide: args.walkthrough_guide,
6554 walkthrough: args.walkthrough,
6555 mark_viewed: &args.mark_viewed,
6556 show_cleared: args.show_cleared,
6557 walkthrough_file: args.walkthrough_file.as_deref(),
6558 show_deprioritized: args.show_deprioritized,
6559 scope: args.scope.clone(),
6560 },
6561 args.gate_marker.as_deref(),
6562 audit::AuditTypeAwareOptions {
6563 enabled: cli.type_aware_override(),
6564 config_default: inputs.audit_cfg.type_aware,
6565 projects: &cli.type_aware_project,
6566 require: cli.type_aware_require.map(Into::into),
6567 },
6568 )
6569}
6570
6571fn dispatch_decision_surface(dispatch: &DispatchContext<'_>, max_decisions: usize) -> ExitCode {
6575 let args = decision_surface_audit_args(max_decisions);
6576 let inputs = match resolve_audit_inputs(dispatch, &args) {
6577 Ok(inputs) => inputs,
6578 Err(code) => return code,
6579 };
6580 if dispatch.cli.fail_on_stale_baseline {
6584 for path in [
6585 &inputs.dead_code_baseline,
6586 &inputs.health_baseline,
6587 &inputs.dupes_baseline,
6588 ] {
6589 baseline_gate::note_stood_down(
6590 path.as_deref(),
6591 true,
6592 "decision-surface renders a brief without exit gates",
6593 );
6594 }
6595 }
6596 audit::run_decision_surface(&decision_surface_audit_options(
6597 dispatch,
6598 &inputs,
6599 max_decisions,
6600 ))
6601}
6602
6603fn decision_surface_audit_args(max_decisions: usize) -> AuditDispatchArgs {
6604 AuditDispatchArgs {
6605 production_dead_code: false,
6606 production_health: false,
6607 production_dupes: false,
6608 dead_code_baseline: None,
6609 health_baseline: None,
6610 dupes_baseline: None,
6611 max_crap: None,
6612 coverage: None,
6613 coverage_root: None,
6614 no_css: true,
6615 css_deep: false,
6616 no_css_deep: false,
6617 gate: None,
6618 runtime_coverage: None,
6619 min_invocations_hot: 0,
6620 gate_marker: None,
6621 brief: true,
6622 max_decisions,
6623 walkthrough_guide: false,
6624 walkthrough_file: None,
6625 walkthrough: false,
6626 mark_viewed: Vec::new(),
6627 show_cleared: false,
6628 show_deprioritized: false,
6629 scope: None,
6630 }
6631}
6632
6633fn decision_surface_audit_options<'a>(
6634 dispatch: &'a DispatchContext<'a>,
6635 inputs: &'a ResolvedAuditInputs,
6636 max_decisions: usize,
6637) -> audit::AuditOptions<'a> {
6638 let cli = dispatch.cli;
6639 audit::AuditOptions {
6640 root: dispatch.root,
6641 config_path: &cli.config,
6642 cache_dir: &inputs.cache_dir,
6643 output: dispatch.output,
6644 json_style: dispatch.json_style,
6645 no_cache: cli.no_cache,
6646 threads: dispatch.threads,
6647 quiet: dispatch.quiet,
6648 allow_remote_extends: cli.allow_remote_extends,
6649 changed_since: cli.changed_since.as_deref(),
6650 production: cli.production,
6651 production_dead_code: Some(inputs.production.dead_code),
6652 production_health: Some(inputs.production.health),
6653 production_dupes: Some(inputs.production.dupes),
6654 workspace: cli.workspace.as_deref(),
6655 changed_workspaces: cli.changed_workspaces.as_deref(),
6656 explain: cli.explain,
6657 explain_skipped: cli.explain_skipped,
6658 performance: cli.performance,
6659 group_by: cli.group_by,
6660 dead_code_baseline: inputs.dead_code_baseline.as_deref(),
6661 health_baseline: inputs.health_baseline.as_deref(),
6662 dupes_baseline: inputs.dupes_baseline.as_deref(),
6663 health_baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
6664 fail_on_stale_baseline: false,
6668 max_crap: None,
6669 coverage: None,
6670 coverage_root: None,
6671 gate: inputs.audit_cfg.gate,
6672 include_entry_exports: cli.include_entry_exports,
6673 fail_on_parse_error: false,
6674 css: false,
6676 css_deep: false,
6677 runtime_coverage: None,
6678 min_invocations_hot: 0,
6679 brief: true,
6680 max_decisions,
6681 walkthrough_guide: false,
6682 walkthrough: false,
6683 mark_viewed: &[],
6684 show_cleared: false,
6685 walkthrough_file: None,
6686 show_deprioritized: false,
6687 scope: None,
6688 }
6689}
6690
6691struct HealthDispatchArgs<'a> {
6692 max_cyclomatic: Option<u16>,
6693 max_cognitive: Option<u16>,
6694 max_crap: Option<f64>,
6695 top: Option<usize>,
6696 sort: health::SortBy,
6697 complexity: bool,
6698 complexity_breakdown: bool,
6699 file_scores: bool,
6700 coverage_gaps: bool,
6701 hotspots: bool,
6702 ownership: bool,
6703 ownership_emails: Option<fallow_config::EmailMode>,
6704 targets: bool,
6705 type_coupling: bool,
6706 css: bool,
6707 effort: Option<EffortFilter>,
6708 score: bool,
6709 min_score: Option<f64>,
6710 min_severity: Option<fallow_output::FindingSeverity>,
6711 report_only: bool,
6712 fail_on_stale_baseline: bool,
6713 fail_on_parse_error: bool,
6714 since: Option<&'a str>,
6715 min_commits: Option<u32>,
6716 save_snapshot: Option<&'a Option<String>>,
6717 trend: bool,
6718 coverage: Option<&'a std::path::Path>,
6719 coverage_root: Option<&'a std::path::Path>,
6720 runtime_coverage: Option<&'a std::path::Path>,
6721 min_invocations_hot: u64,
6722 min_observation_volume: Option<u32>,
6723 low_traffic_threshold: Option<f64>,
6724 scope: Option<std::path::PathBuf>,
6725}
6726
6727type ResolvedHealthCoverageInputs = fallow_api::CoverageInputs;
6728
6729fn resolve_coverage_inputs(
6740 cli_coverage: Option<&std::path::Path>,
6741 cli_coverage_root: Option<&std::path::Path>,
6742 output: fallow_config::OutputFormat,
6743 config_health: impl FnOnce() -> Result<fallow_config::HealthConfig, ExitCode>,
6744) -> Result<ResolvedHealthCoverageInputs, ExitCode> {
6745 let explicit = fallow_api::CoverageInputs {
6746 coverage: cli_coverage.map(std::path::Path::to_path_buf),
6747 coverage_root: cli_coverage_root.map(std::path::Path::to_path_buf),
6748 };
6749 let env = fallow_api::CoverageInputs {
6750 coverage: path_from_env("FALLOW_COVERAGE"),
6751 coverage_root: path_from_env("FALLOW_COVERAGE_ROOT"),
6752 };
6753 let config_health = if fallow_api::CoverageInputs::needs_config_layer(&explicit, &env) {
6754 Some(config_health()?)
6755 } else {
6756 None
6757 };
6758
6759 fallow_api::resolve_coverage_inputs(explicit, env, config_health.as_ref())
6760 .map_err(|err| emit_error(&err.to_string(), 2, output))
6761}
6762
6763fn resolve_health_coverage_inputs(
6766 dispatch: &DispatchContext<'_>,
6767 cli_coverage: Option<&std::path::Path>,
6768 cli_coverage_root: Option<&std::path::Path>,
6769) -> Result<ResolvedHealthCoverageInputs, ExitCode> {
6770 resolve_coverage_inputs(cli_coverage, cli_coverage_root, dispatch.output, || {
6771 Ok(load_config(
6772 dispatch.root,
6773 &dispatch.cli.config,
6774 LoadConfigArgs {
6775 output: dispatch.output,
6776 no_cache: dispatch.cli.no_cache,
6777 threads: dispatch.threads,
6778 production: dispatch.cli.production,
6779 quiet: dispatch.quiet,
6780 allow_remote_extends: dispatch.cli.allow_remote_extends,
6781 },
6782 )?
6783 .health)
6784 })
6785}
6786
6787fn path_from_env(name: &str) -> Option<PathBuf> {
6788 std::env::var_os(name)
6789 .filter(|value| !value.is_empty())
6790 .map(PathBuf::from)
6791}
6792
6793fn validate_health_report_only_gate(
6794 report_only: bool,
6795 min_score: Option<f64>,
6796 min_severity: Option<fallow_output::FindingSeverity>,
6797 output: fallow_config::OutputFormat,
6798) -> Result<(), ExitCode> {
6799 if report_only && (min_score.is_some() || min_severity.is_some()) {
6800 return Err(emit_error(
6801 "--report-only cannot be combined with --min-score or --min-severity. \
6802 --report-only always exits 0; drop it to gate on score/severity, or \
6803 drop the gate flags to stay advisory.",
6804 2,
6805 output,
6806 ));
6807 }
6808
6809 Ok(())
6810}
6811
6812fn resolve_runtime_coverage_options(
6813 runtime_coverage: Option<&std::path::Path>,
6814 min_invocations_hot: u64,
6815 min_observation_volume: Option<u32>,
6816 low_traffic_threshold: Option<f64>,
6817 output: fallow_config::OutputFormat,
6818) -> Result<Option<fallow_engine::health::RuntimeCoverageOptions>, ExitCode> {
6819 let Some(path) = runtime_coverage else {
6820 return Ok(None);
6821 };
6822
6823 health::coverage::prepare_options(
6824 path,
6825 min_invocations_hot,
6826 min_observation_volume,
6827 low_traffic_threshold,
6828 output,
6829 )
6830 .map(Some)
6831}
6832
6833fn dispatch_health(dispatch: &DispatchContext<'_>, args: &HealthDispatchArgs<'_>) -> ExitCode {
6834 let cli = dispatch.cli;
6835 let root = dispatch.root;
6836 let (output, _quiet, _fail_on_issues) =
6837 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
6838 if let Err(code) = validate_health_report_only_gate(
6839 args.report_only,
6840 args.min_score,
6841 args.min_severity,
6842 output,
6843 ) {
6844 return code;
6845 }
6846 let runtime_coverage = match resolve_runtime_coverage_options(
6847 args.runtime_coverage,
6848 args.min_invocations_hot,
6849 args.min_observation_volume,
6850 args.low_traffic_threshold,
6851 output,
6852 ) {
6853 Ok(options) => options,
6854 Err(code) => return code,
6855 };
6856 let production = match resolve_production_modes(cli, root, output, false, false, false) {
6857 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::Health),
6858 Err(code) => return code,
6859 };
6860 let coverage_inputs =
6861 match resolve_health_coverage_inputs(dispatch, args.coverage, args.coverage_root) {
6862 Ok(inputs) => inputs,
6863 Err(code) => return code,
6864 };
6865 let run = derive_health_dispatch_run(args, output, &coverage_inputs, runtime_coverage);
6866 let resolved = ResolvedHealthDispatch { run, production };
6867 if args.report_only && cli.fail_on_baseline_growth {
6870 baseline_growth::note_stood_down(
6871 dispatch.growth_flags(),
6872 "health --report-only never fails a run",
6873 );
6874 return run_health_dispatch(dispatch, args, resolved);
6875 }
6876 let growth_targets = baseline_growth::targets(&[(
6877 cli.baseline.as_deref(),
6878 fallow_engine::baseline::BaselineKind::Health,
6879 )]);
6880 baseline_growth::run_gated(
6881 &dispatch.growth_ctx(baseline_growth::GrowthOwner::Health),
6882 &growth_targets,
6883 || run_health_dispatch(dispatch, args, resolved),
6884 )
6885}
6886
6887fn derive_health_dispatch_run<'a>(
6888 args: &'a HealthDispatchArgs<'a>,
6889 output: fallow_config::OutputFormat,
6890 coverage_inputs: &'a ResolvedHealthCoverageInputs,
6891 runtime_coverage: Option<fallow_engine::health::RuntimeCoverageOptions>,
6892) -> fallow_engine::health::HealthRunOptions<'a> {
6893 let mut run = fallow_engine::health::derive_health_run_options(
6894 fallow_engine::health::HealthRunOptionsInput {
6895 output,
6896 thresholds: health_threshold_overrides(args),
6897 top: args.top,
6898 sort: args.sort.clone().into(),
6899 complexity: args.complexity,
6900 file_scores: args.file_scores,
6901 coverage_gaps: args.coverage_gaps,
6902 hotspots: args.hotspots,
6903 ownership: args.ownership,
6904 ownership_emails: args.ownership_emails,
6905 targets: args.targets,
6906 css: args.css,
6907 effort: args.effort.map(EffortFilter::to_estimate),
6908 score: args.score,
6909 gates: health_gate_options(args),
6910 snapshot_requested: args.save_snapshot.is_some(),
6911 trend: args.trend,
6912 since: args.since,
6913 min_commits: args.min_commits,
6914 coverage_inputs: health_coverage_inputs(coverage_inputs),
6915 runtime_coverage,
6916 },
6917 );
6918 if args.type_coupling && !run.sections.any_section {
6919 run.sections = fallow_engine::health::DerivedHealthSections {
6920 any_section: true,
6921 complexity: false,
6922 file_scores: false,
6923 coverage_gaps: false,
6924 hotspots: false,
6925 targets: false,
6926 css: false,
6927 score: false,
6928 force_full: false,
6929 score_only_output: false,
6930 };
6931 }
6932 run
6933}
6934
6935fn health_threshold_overrides(
6936 args: &HealthDispatchArgs<'_>,
6937) -> fallow_engine::health::HealthThresholdOverrides {
6938 fallow_engine::health::HealthThresholdOverrides {
6939 max_cyclomatic: args.max_cyclomatic,
6940 max_cognitive: args.max_cognitive,
6941 max_crap: args.max_crap,
6942 }
6943}
6944
6945fn health_gate_options(args: &HealthDispatchArgs<'_>) -> fallow_engine::health::HealthGateOptions {
6946 fallow_engine::health::HealthGateOptions {
6947 min_score: args.min_score,
6948 min_severity: args.min_severity,
6949 report_only: args.report_only,
6950 fail_on_stale_baseline: args.fail_on_stale_baseline,
6951 fail_on_parse_error: args.fail_on_parse_error,
6952 fail_on_issues: false,
6953 }
6954}
6955
6956fn health_coverage_inputs(
6957 coverage_inputs: &ResolvedHealthCoverageInputs,
6958) -> fallow_engine::health::HealthCoverageInputs<'_> {
6959 fallow_engine::health::HealthCoverageInputs {
6960 coverage: coverage_inputs.coverage.as_deref(),
6961 coverage_root: coverage_inputs.coverage_root.as_deref(),
6962 coverage_relocated: false,
6963 }
6964}
6965
6966struct ResolvedHealthDispatch<'a> {
6970 run: fallow_engine::health::HealthRunOptions<'a>,
6971 production: bool,
6972}
6973
6974fn run_health_dispatch(
6977 dispatch: &DispatchContext<'_>,
6978 args: &HealthDispatchArgs<'_>,
6979 resolved: ResolvedHealthDispatch<'_>,
6980) -> ExitCode {
6981 let cli = dispatch.cli;
6982 let (output, quiet, fail_on_issues) =
6983 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
6984 let mut run = resolved.run;
6985 run.gates.fail_on_issues = fail_on_issues;
6986 let sections = run.sections;
6987 let production = resolved.production;
6988 health::run_health(
6989 &HealthOptions {
6990 root: dispatch.root,
6991 config_path: &cli.config,
6992 output,
6993 no_cache: cli.no_cache,
6994 threads: dispatch.threads,
6995 quiet,
6996 thresholds: run.thresholds,
6997 top: run.top,
6998 sort: run.sort,
6999 production,
7000 production_override: Some(production),
7001 allow_remote_extends: cli.allow_remote_extends,
7002 changed_since: cli.changed_since.as_deref(),
7003 diff_index: None,
7004 use_shared_diff_index: true,
7005 workspace: cli.workspace.as_deref(),
7006 changed_workspaces: cli.changed_workspaces.as_deref(),
7007 baseline: cli.baseline.as_deref(),
7008 save_baseline: cli.save_baseline.as_deref(),
7009 baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
7010 baseline_mode_explicit: cli.baseline_mode.is_some(),
7011 complexity: sections.complexity,
7012 file_scores: sections.file_scores,
7013 coverage_gaps: sections.coverage_gaps,
7014 config_activates_coverage_gaps: !sections.any_section,
7015 hotspots: sections.hotspots,
7016 ownership: run.ownership,
7017 ownership_emails: run.ownership_emails,
7018 targets: sections.targets,
7019 css: sections.css,
7020 css_deep: false,
7021 force_full: sections.force_full,
7022 score_only_output: sections.score_only_output,
7023 enforce_coverage_gap_gate: true,
7024 effort: run.effort,
7025 score: sections.score,
7026 gates: run.gates,
7027 since: run.since,
7028 min_commits: run.min_commits,
7029 explain: cli.explain,
7030 summary: cli.summary,
7031 save_snapshot: args
7032 .save_snapshot
7033 .map(|opt| PathBuf::from(opt.as_deref().unwrap_or_default())),
7034 trend: args.trend,
7035 coverage_inputs: run.coverage_inputs,
7036 performance: cli.performance,
7037 runtime_coverage: run.runtime_coverage,
7038 churn_file: cli.churn_file.as_deref(),
7039 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
7040 complexity_breakdown: args.complexity_breakdown,
7041 group_by: cli.group_by.map(Into::into),
7042 scope: args.scope.clone(),
7043 },
7044 dispatch.json_style,
7045 &health::TypeAwareHealthOptions {
7046 enabled: cli.type_aware_override(),
7047 requested: args.type_coupling,
7048 unfiltered: health_type_coupling_is_default_section(args),
7049 projects: &cli.type_aware_project,
7050 require: cli.type_aware_require.map(Into::into),
7051 },
7052 )
7053}
7054
7055fn health_type_coupling_is_default_section(args: &HealthDispatchArgs<'_>) -> bool {
7056 !args.complexity
7057 && !args.file_scores
7058 && !args.coverage_gaps
7059 && !args.hotspots
7060 && !args.ownership
7061 && !args.targets
7062 && !args.css
7063 && !args.score
7064 && args.min_score.is_none()
7065 && args.min_severity.is_none()
7066 && args.runtime_coverage.is_none()
7067}
7068
7069#[cfg(test)]
7070mod tests {
7071 use super::*;
7072
7073 #[test]
7077 fn cli_definition_has_no_flag_collisions() {
7078 use clap::CommandFactory;
7079 Cli::command().debug_assert();
7080 }
7081
7082 #[test]
7083 fn impact_statusline_subcommand_parses() {
7084 use clap::Parser;
7085
7086 let cli = Cli::try_parse_from(["fallow", "impact", "statusline"]).expect("argv parses");
7087 assert!(matches!(
7088 cli.command,
7089 Some(Command::Impact {
7090 subcommand: Some(ImpactCli::Statusline),
7091 ..
7092 })
7093 ));
7094 }
7095
7096 #[test]
7097 fn impact_statusline_bypasses_command_epilogue() {
7098 use clap::Parser;
7099
7100 let statusline =
7101 Cli::try_parse_from(["fallow", "impact", "statusline"]).expect("argv parses");
7102 assert!(is_impact_statusline(&statusline));
7103
7104 let status = Cli::try_parse_from(["fallow", "impact", "status"]).expect("argv parses");
7105 assert!(!is_impact_statusline(&status));
7106
7107 let all_statusline =
7108 Cli::try_parse_from(["fallow", "impact", "--all", "statusline"]).expect("argv parses");
7109 assert!(!is_impact_statusline(&all_statusline));
7110 }
7111
7112 #[test]
7113 fn regression_baseline_help_explains_the_default_destination() {
7114 use clap::CommandFactory;
7115 let help = Cli::command().render_long_help().to_string();
7116
7117 assert!(help.contains("Omit PATH to update regression.baseline"));
7118 assert!(help.contains("discovered fallow config"));
7119 assert!(help.contains("create .fallowrc.json when none exists"));
7120 }
7121
7122 #[test]
7126 fn after_help_lists_every_task_matrix_command() {
7127 for row in crate::task_matrix::TASK_MATRIX {
7128 assert!(
7129 TOP_LEVEL_AFTER_LONG_HELP.contains(row.command),
7130 "root --help cheat sheet is missing task-matrix command '{}'; \
7131 update the top_level_task_cheat_sheet! fragment to match TASK_MATRIX",
7132 row.command
7133 );
7134 }
7135 }
7136
7137 #[test]
7144 fn after_help_lists_every_visible_subcommand() {
7145 use clap::CommandFactory;
7146
7147 for sub in Cli::command().get_subcommands() {
7148 if sub.is_hide_set() {
7149 continue;
7150 }
7151 let name = sub.get_name();
7152 let listed = TOP_LEVEL_AFTER_LONG_HELP
7153 .lines()
7154 .any(|line| line.split_whitespace().next() == Some(name));
7155 assert!(
7156 listed,
7157 "root --help command list is missing subcommand '{name}'; \
7158 add it to a top_level_*_command_groups! section"
7159 );
7160 }
7161 }
7162
7163 #[test]
7167 fn short_help_stays_scannable_with_cheat_sheet_and_pointer() {
7168 use clap::CommandFactory;
7169
7170 let help = Cli::command().render_help().to_string();
7171 let lines = help.lines().count();
7172 assert!(
7173 lines < 90,
7174 "root -h grew to {lines} lines; keep the short surface under 90 \
7175 (curate hide_short_help and the short after-help instead)"
7176 );
7177 assert!(help.contains("When the agent is about to..."));
7178 assert!(help.contains("Run fallow --help for the complete command list."));
7179 }
7180
7181 #[test]
7185 fn high_value_commands_route_to_distinct_workflows() {
7186 use clap::Parser;
7187 use fallow_config::OutputFormat;
7188
7189 let distinct = [
7190 (vec!["fallow", "impact"], telemetry::Workflow::Impact),
7191 (vec!["fallow", "security"], telemetry::Workflow::Security),
7192 (vec!["fallow", "fix"], telemetry::Workflow::Fix),
7193 (
7194 vec!["fallow", "explain", "unused-exports"],
7195 telemetry::Workflow::Explain,
7196 ),
7197 (
7198 vec!["fallow", "watch"],
7199 telemetry::Workflow::CodeQualityReview,
7200 ),
7201 (
7202 vec!["fallow", "list"],
7203 telemetry::Workflow::ProjectInventory,
7204 ),
7205 (
7206 vec!["fallow", "workspaces"],
7207 telemetry::Workflow::ProjectInventory,
7208 ),
7209 (
7210 vec!["fallow", "schema"],
7211 telemetry::Workflow::ProjectInventory,
7212 ),
7213 (vec!["fallow", "init"], telemetry::Workflow::Setup),
7214 (
7215 vec!["fallow", "hooks", "install", "--target", "git"],
7216 telemetry::Workflow::Setup,
7217 ),
7218 (vec!["fallow", "config-schema"], telemetry::Workflow::Setup),
7219 (vec!["fallow", "plugin-schema"], telemetry::Workflow::Setup),
7220 (
7221 vec!["fallow", "rule-pack-schema"],
7222 telemetry::Workflow::Setup,
7223 ),
7224 (vec!["fallow", "config"], telemetry::Workflow::Setup),
7225 (
7226 vec!["fallow", "ci-template", "gitlab"],
7227 telemetry::Workflow::Setup,
7228 ),
7229 (vec!["fallow", "migrate"], telemetry::Workflow::Setup),
7230 (
7231 vec!["fallow", "telemetry", "status"],
7232 telemetry::Workflow::Setup,
7233 ),
7234 (vec!["fallow", "setup-hooks"], telemetry::Workflow::Setup),
7235 (
7236 vec!["fallow", "audit-cache", "remove", "--root", "."],
7237 telemetry::Workflow::Setup,
7238 ),
7239 (
7240 vec!["fallow", "license", "status"],
7241 telemetry::Workflow::License,
7242 ),
7243 ];
7244 for (argv, expected) in distinct {
7245 let cli = Cli::try_parse_from(&argv).expect("argv parses");
7246 assert_eq!(
7247 telemetry_workflow_for_command(cli.command.as_ref(), OutputFormat::Json),
7248 expected,
7249 "{argv:?} should map to {expected:?}"
7250 );
7251 }
7252 }
7253
7254 #[test]
7259 fn version_flag_accepts_lower_v_upper_v_and_long() {
7260 use clap::CommandFactory;
7261 for argv in [["fallow", "-v"], ["fallow", "-V"], ["fallow", "--version"]] {
7262 let err = Cli::command()
7263 .try_get_matches_from(argv)
7264 .expect_err("version flag should short-circuit parsing");
7265 assert_eq!(
7266 err.kind(),
7267 clap::error::ErrorKind::DisplayVersion,
7268 "{argv:?} should trigger the Version action"
7269 );
7270 }
7271 }
7272
7273 #[test]
7278 fn cli_help_text_contains_no_implementation_status_wording() {
7279 use clap::CommandFactory;
7280 let mut root = Cli::command();
7281 let mut violations: Vec<(String, String)> = Vec::new();
7282 visit_help(&mut root, "fallow", &mut violations);
7283 assert!(
7284 violations.is_empty(),
7285 "found implementation-status wording in --help output:\n{}",
7286 violations
7287 .iter()
7288 .map(|(cmd, line)| format!(" {cmd}: {line}"))
7289 .collect::<Vec<_>>()
7290 .join("\n")
7291 );
7292 }
7293
7294 #[test]
7295 fn dependency_override_help_is_package_manager_neutral() {
7296 use clap::CommandFactory;
7297 let help = Cli::command()
7298 .find_subcommand_mut("dead-code")
7299 .expect("dead-code command")
7300 .render_long_help()
7301 .to_string();
7302
7303 assert!(help.contains("Only report unused package-manager dependency overrides"));
7304 assert!(help.contains("Only report misconfigured package-manager dependency overrides"));
7305 assert!(!help.contains("unused pnpm dependency overrides"));
7306 assert!(!help.contains("misconfigured pnpm dependency overrides"));
7307 }
7308
7309 #[test]
7310 fn top_level_help_groups_commands_by_workflow() {
7311 use clap::CommandFactory;
7312 let help = Cli::command().render_long_help().to_string();
7313 let expected_order = [
7314 "Analysis:",
7315 " dead-code",
7316 " dupes",
7317 " health",
7318 " flags",
7319 " security",
7320 " audit",
7321 "Workflow:",
7322 " watch",
7323 " fix",
7324 "Project inspection:",
7325 " list",
7326 " workspaces",
7327 " explain",
7328 " impact",
7329 " viz",
7330 "Setup and configuration:",
7331 " init",
7332 " recommend",
7333 " migrate",
7334 " config",
7335 " config-schema",
7336 " plugin-schema",
7337 " plugin-check",
7338 " rule-pack-schema",
7339 "Automation and CI:",
7340 " ci",
7341 " ci-template",
7342 " hooks",
7343 " setup-hooks",
7344 "Runtime coverage:",
7345 " coverage",
7346 " license",
7347 "Reference:",
7348 " schema",
7349 " help",
7350 "Options:",
7351 ];
7352 let mut cursor = 0;
7353 for needle in expected_order {
7354 let Some(offset) = help[cursor..].find(needle) else {
7355 panic!("top-level help missing `{needle}` after byte {cursor}:\n{help}");
7356 };
7357 cursor += offset + needle.len();
7358 }
7359 }
7360
7361 #[test]
7362 fn security_help_hides_globals_rejected_by_security_validator() {
7363 let help = render_security_help(SecurityHelpTarget::Parent);
7364
7365 for long in SECURITY_UNSUPPORTED_GLOBAL_LONGS {
7366 assert!(
7367 !help_contains_long_flag(&help, long),
7368 "security help must hide unsupported --{long}:\n{help}"
7369 );
7370 }
7371
7372 for long in [
7373 "root",
7374 "config",
7375 "format",
7376 "quiet",
7377 "no-cache",
7378 "threads",
7379 "changed-since",
7380 "diff-file",
7381 "diff-stdin",
7382 "workspace",
7383 "changed-workspaces",
7384 "ci",
7385 "fail-on-issues",
7386 "sarif-file",
7387 "summary",
7388 "output-file",
7389 "max-file-size",
7390 "explain",
7391 "surface",
7392 ] {
7393 assert!(
7394 help_contains_long_flag(&help, long),
7395 "security help must keep supported --{long}:\n{help}"
7396 );
7397 }
7398 }
7399
7400 #[test]
7401 fn security_help_detection_covers_subcommand_and_help_alias_forms() {
7402 assert_eq!(
7403 security_help_target(["security", "--help"]),
7404 Some(SecurityHelpTarget::Parent)
7405 );
7406 assert_eq!(
7407 security_help_target(["security", "-h"]),
7408 Some(SecurityHelpTarget::Parent)
7409 );
7410 assert_eq!(
7411 security_help_target(["--format", "json", "security", "--help"]),
7412 Some(SecurityHelpTarget::Parent)
7413 );
7414 assert_eq!(
7415 security_help_target(["help", "security"]),
7416 Some(SecurityHelpTarget::Parent)
7417 );
7418 assert_eq!(
7419 security_help_target(["security", "survivors", "--help"]),
7420 Some(SecurityHelpTarget::Survivors)
7421 );
7422 assert_eq!(
7423 security_help_target(["security", "survivors", "-h"]),
7424 Some(SecurityHelpTarget::Survivors)
7425 );
7426 assert_eq!(
7427 security_help_target(["help", "security", "survivors"]),
7428 Some(SecurityHelpTarget::Survivors)
7429 );
7430 assert_eq!(
7431 security_help_target(["security", "blind-spots", "--help"]),
7432 Some(SecurityHelpTarget::BlindSpots)
7433 );
7434 assert_eq!(
7435 security_help_target(["help", "security", "blind-spots"]),
7436 Some(SecurityHelpTarget::BlindSpots)
7437 );
7438 assert_eq!(security_help_target(["health", "--help"]), None);
7439 assert_eq!(security_help_target(["help", "health"]), None);
7440 }
7441
7442 #[test]
7443 fn security_unsupported_global_validator_matches_hidden_help_contract() {
7444 for (argv, expected) in [
7445 (vec!["fallow", "security", "--performance"], "--performance"),
7446 (
7447 vec!["fallow", "security", "--no-package-baselines"],
7448 "--no-package-baselines",
7449 ),
7450 (
7451 vec!["fallow", "security", "--baseline", "base.json"],
7452 "--baseline",
7453 ),
7454 (
7455 vec!["fallow", "security", "--fail-on-stale-baseline"],
7456 "--fail-on-stale-baseline",
7457 ),
7458 (
7459 vec!["fallow", "security", "--dupes-mode", "weak"],
7460 "--dupes-mode",
7461 ),
7462 ] {
7463 let cli = Cli::try_parse_from(argv).expect("security global parses before validation");
7464 assert_eq!(unsupported_security_global(&cli), Some(expected));
7465 }
7466
7467 let explain = Cli::try_parse_from(["fallow", "security", "--explain"])
7468 .expect("security --explain parses");
7469 assert_eq!(unsupported_security_global(&explain), None);
7470 }
7471
7472 #[test]
7473 fn programmatic_common_options_track_analysis_affecting_cli_globals() {
7474 use clap::CommandFactory;
7475
7476 let cli_flags: std::collections::BTreeSet<String> = Cli::command()
7477 .get_arguments()
7478 .filter(|arg| arg.is_global_set())
7479 .filter_map(|arg| arg.get_long().map(str::to_owned))
7480 .filter(|name| {
7481 matches!(
7482 name.as_str(),
7483 "root"
7484 | "config"
7485 | "allow-remote-extends"
7486 | "no-cache"
7487 | "threads"
7488 | "changed-since"
7489 | "diff-file"
7490 | "production"
7491 | "workspace"
7492 | "changed-workspaces"
7493 | "explain"
7494 )
7495 })
7496 .collect();
7497 let programmatic_flags: std::collections::BTreeSet<String> =
7498 fallow_api::COMMON_ANALYSIS_OPTION_FLAGS
7499 .iter()
7500 .map(|flag| (*flag).to_owned())
7501 .collect();
7502
7503 assert_eq!(programmatic_flags, cli_flags);
7504 }
7505
7506 #[test]
7507 fn dead_code_registry_filter_flags_are_exposed_by_clap() {
7508 use clap::CommandFactory;
7509
7510 let cli = Cli::command();
7511 let dead_code = cli
7512 .get_subcommands()
7513 .find(|command| command.get_name() == "dead-code")
7514 .expect("dead-code subcommand is registered");
7515 let cli_flags: std::collections::BTreeSet<String> = dead_code
7516 .get_arguments()
7517 .filter_map(|arg| arg.get_long().map(|long| format!("--{long}")))
7518 .collect();
7519
7520 for flag in fallow_types::issue_meta::DEAD_CODE_FILTER_FLAGS.iter() {
7521 assert!(
7522 cli_flags.contains(*flag),
7523 "registry filter flag {flag} is missing from dead-code clap args"
7524 );
7525 }
7526 }
7527
7528 fn help_contains_long_flag(help: &str, long: &str) -> bool {
7529 let flag = format!("--{long}");
7530 help.split(|c: char| c.is_whitespace() || c == ',' || c == '[' || c == ']')
7531 .any(|token| token == flag)
7532 }
7533
7534 fn visit_help(cmd: &mut clap::Command, path: &str, violations: &mut Vec<(String, String)>) {
7535 let help = cmd.render_long_help().to_string();
7536 for line in scan_forbidden(&help) {
7537 violations.push((path.to_owned(), line));
7538 }
7539 let names: Vec<String> = cmd
7540 .get_subcommands()
7541 .map(|sub| sub.get_name().to_owned())
7542 .collect();
7543 for name in names {
7544 if name == "help" {
7545 continue;
7546 }
7547 if let Some(sub) = cmd.find_subcommand_mut(&name) {
7548 let sub_path = format!("{path} {name}");
7549 visit_help(sub, &sub_path, violations);
7550 }
7551 }
7552 }
7553
7554 fn scan_forbidden(s: &str) -> Vec<String> {
7555 let lower = s.to_ascii_lowercase();
7556 let mut out = Vec::new();
7557 for word in ["stub", "placeholder"] {
7558 if let Some(idx) = find_whole_word(&lower, word) {
7559 out.push(extract_line(s, idx));
7560 }
7561 }
7562 if let Some(idx) = lower.find("not yet") {
7563 out.push(extract_line(s, idx));
7564 }
7565 out
7566 }
7567
7568 fn find_whole_word(haystack: &str, word: &str) -> Option<usize> {
7569 let bytes = haystack.as_bytes();
7570 let mut start = 0;
7571 while let Some(rel) = haystack[start..].find(word) {
7572 let abs = start + rel;
7573 let before_ok = abs == 0 || !bytes[abs - 1].is_ascii_alphanumeric();
7574 let after_idx = abs + word.len();
7575 let after_ok = after_idx >= bytes.len() || !bytes[after_idx].is_ascii_alphanumeric();
7576 if before_ok && after_ok {
7577 return Some(abs);
7578 }
7579 start = abs + word.len();
7580 }
7581 None
7582 }
7583
7584 fn extract_line(s: &str, byte_idx: usize) -> String {
7585 let line_start = s[..byte_idx].rfind('\n').map_or(0, |i| i + 1);
7586 let line_end = s[byte_idx..].find('\n').map_or(s.len(), |i| byte_idx + i);
7587 s[line_start..line_end].trim().to_owned()
7588 }
7589
7590 #[test]
7591 fn emit_error_returns_given_exit_code() {
7592 let code = emit_error("test error", 2, fallow_config::OutputFormat::Human);
7593 assert_eq!(code, ExitCode::from(2));
7594 }
7595
7596 fn telemetry_run_for_mode(mode: telemetry::AnalysisMode) -> TelemetryRun {
7597 TelemetryRun {
7598 workflow: telemetry::Workflow::Health,
7599 output: fallow_config::OutputFormat::Json,
7600 quiet: true,
7601 start: std::time::Instant::now(),
7602 context: telemetry::WorkflowContext {
7603 run_scope: telemetry::RunScope::FullProject,
7604 config_shape: telemetry::ConfigShape::Default,
7605 output_destination: telemetry::OutputDestination::Stdout,
7606 analysis_mode: mode,
7607 },
7608 }
7609 }
7610
7611 #[test]
7612 fn fallback_failure_reason_skips_success_and_findings() {
7613 let run = telemetry_run_for_mode(telemetry::AnalysisMode::Static);
7614
7615 assert_eq!(fallback_failure_reason_for(&run, ExitCode::SUCCESS), None);
7616 assert_eq!(fallback_failure_reason_for(&run, ExitCode::from(1)), None);
7617 }
7618
7619 #[test]
7620 fn fallback_failure_reason_classifies_network_auth_and_analysis() {
7621 let static_run = telemetry_run_for_mode(telemetry::AnalysisMode::Static);
7622 let cloud_run = telemetry_run_for_mode(telemetry::AnalysisMode::ProductionCoverage);
7623
7624 assert_eq!(
7625 fallback_failure_reason_for(&static_run, ExitCode::from(api::NETWORK_EXIT_CODE)),
7626 Some(telemetry::FailureReason::Network),
7627 );
7628 assert_eq!(
7629 fallback_failure_reason_for(&static_run, ExitCode::from(12)),
7630 Some(telemetry::FailureReason::Auth),
7631 );
7632 assert_eq!(
7633 fallback_failure_reason_for(&cloud_run, ExitCode::from(3)),
7634 Some(telemetry::FailureReason::Auth),
7635 );
7636 assert_eq!(
7637 fallback_failure_reason_for(&static_run, ExitCode::from(2)),
7638 Some(telemetry::FailureReason::Analysis),
7639 );
7640 }
7641
7642 #[test]
7643 fn bare_coverage_flags_parse_without_subcommand() {
7644 let cli = Cli::try_parse_from([
7645 "fallow",
7646 "--coverage",
7647 "coverage/coverage-final.json",
7648 "--coverage-root",
7649 "/ci/workspace",
7650 ])
7651 .expect("bare combined coverage flags should parse");
7652 assert!(cli.command.is_none());
7653 assert_eq!(
7654 cli.coverage.as_deref(),
7655 Some(std::path::Path::new("coverage/coverage-final.json"))
7656 );
7657 assert_eq!(
7658 cli.coverage_root.as_deref(),
7659 Some(std::path::Path::new("/ci/workspace"))
7660 );
7661 }
7662
7663 #[test]
7664 fn bare_coverage_before_subcommand_is_detectable() {
7665 let cli = Cli::try_parse_from([
7666 "fallow",
7667 "--coverage",
7668 "coverage/coverage-final.json",
7669 "dead-code",
7670 ])
7671 .expect("clap should parse pre-subcommand bare coverage for custom rejection");
7672 assert!(cli.command.is_some());
7673 assert!(cli_has_bare_coverage_input(&cli));
7674 let message = bare_coverage_subcommand_error_message();
7675 assert!(message.contains("bare combined-mode flags"));
7676 assert!(message.contains("fallow health --coverage <coverage-final.json>"));
7677 }
7678
7679 #[test]
7680 fn bare_combined_baseline_before_subcommand_is_detectable() {
7681 for flag in ["--dupes-baseline", "--health-baseline"] {
7682 let cli = Cli::try_parse_from(["fallow", flag, "x.json", "dead-code"])
7683 .expect("clap should parse a pre-subcommand combined baseline");
7684 assert!(cli.command.is_some());
7685 assert_eq!(cli_bare_combined_baseline_flag(&cli), Some(flag));
7686 let message = bare_combined_baseline_subcommand_error_message(flag);
7687 assert!(message.contains(flag));
7688 assert!(message.contains("omit the subcommand"));
7689 }
7690 let bare = Cli::try_parse_from(["fallow", "--dupes-baseline", "x.json"])
7691 .expect("bare combined baseline should parse");
7692 assert!(bare.command.is_none());
7693 }
7694
7695 #[test]
7696 fn subcommand_coverage_flag_keeps_regular_clap_error() {
7697 let Err(err) = Cli::try_parse_from(["fallow", "dead-code", "--coverage"]) else {
7698 panic!("dead-code --coverage should fail to parse");
7699 };
7700 assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
7701 }
7702
7703 #[test]
7704 fn type_aware_flags_parse_for_semantic_analysis() {
7705 let cli = Cli::try_parse_from([
7706 "fallow",
7707 "dead-code",
7708 "--unused-class-members",
7709 "--type-aware",
7710 "--type-aware-project",
7711 "tsconfig.json",
7712 "--type-aware-project",
7713 "packages/web/tsconfig.json",
7714 ])
7715 .expect("type-aware flag should parse");
7716 assert!(cli.type_aware);
7717 assert_eq!(
7718 cli.type_aware_project,
7719 [
7720 PathBuf::from("tsconfig.json"),
7721 PathBuf::from("packages/web/tsconfig.json")
7722 ]
7723 );
7724 let Some(Command::Check {
7725 unused_class_members,
7726 ..
7727 }) = cli.command
7728 else {
7729 panic!("dead-code should parse as the check command");
7730 };
7731 assert!(unused_class_members);
7732 }
7733
7734 #[test]
7735 fn no_type_aware_conflicts_with_type_aware() {
7736 let Err(err) = Cli::try_parse_from(["fallow", "audit", "--type-aware", "--no-type-aware"])
7737 else {
7738 panic!("--no-type-aware must conflict with --type-aware");
7739 };
7740 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
7741 }
7742
7743 #[test]
7744 fn no_type_aware_forces_semantic_analysis_off() {
7745 let cli = Cli::try_parse_from(["fallow", "audit", "--no-type-aware"])
7746 .expect("--no-type-aware should parse on audit");
7747 assert_eq!(cli.type_aware_override(), Some(false));
7748
7749 let cli = Cli::try_parse_from(["fallow", "dead-code", "--type-aware"])
7750 .expect("--type-aware should parse");
7751 assert_eq!(cli.type_aware_override(), Some(true));
7752
7753 let cli = Cli::try_parse_from(["fallow", "dead-code"]).expect("bare command should parse");
7754 assert_eq!(cli.type_aware_override(), None);
7755 }
7756
7757 #[test]
7758 fn type_aware_status_output_hides_host_paths() {
7759 let root = Path::new("/private/work/project");
7760 let output = type_aware_status_output(
7761 root,
7762 fallow_api::TypeAwareStatus {
7763 available: false,
7764 discovery_source: Some("environment-override"),
7765 companion_path: Some(PathBuf::from("/private/tools/fallow-type-aware")),
7766 package_version: None,
7767 protocol_version: 7,
7768 backend_family: None,
7769 backend_version: None,
7770 remediation: Some(
7771 "failed to launch /private/tools/fallow-type-aware from /private/work/project"
7772 .to_string(),
7773 ),
7774 },
7775 );
7776
7777 assert_eq!(
7778 output.schema_version.0,
7779 fallow_output::TYPE_AWARE_STATUS_SCHEMA_VERSION
7780 );
7781 assert_eq!(output.companion_path.as_deref(), Some("fallow-type-aware"));
7782 let remediation = output.remediation.expect("remediation");
7783 assert!(!remediation.contains("/private/"));
7784 assert!(remediation.contains("fallow-type-aware"));
7785 }
7786
7787 #[test]
7788 fn format_parsing_covers_all_variants() {
7789 assert!(matches!(parse_format_arg("json"), Some(Format::Json)));
7790 assert!(matches!(parse_format_arg("JSON"), Some(Format::Json)));
7791 assert!(matches!(parse_format_arg("human"), Some(Format::Human)));
7792 assert!(matches!(parse_format_arg("sarif"), Some(Format::Sarif)));
7793 assert!(matches!(parse_format_arg("compact"), Some(Format::Compact)));
7794 assert!(matches!(
7795 parse_format_arg("markdown"),
7796 Some(Format::Markdown)
7797 ));
7798 assert!(matches!(parse_format_arg("md"), Some(Format::Markdown)));
7799 assert!(matches!(
7800 parse_format_arg("codeclimate"),
7801 Some(Format::CodeClimate)
7802 ));
7803 assert!(matches!(
7804 parse_format_arg("gitlab-codequality"),
7805 Some(Format::CodeClimate)
7806 ));
7807 assert!(matches!(
7808 parse_format_arg("gitlab-code-quality"),
7809 Some(Format::CodeClimate)
7810 ));
7811 assert!(matches!(
7812 parse_format_arg("pr-comment-github"),
7813 Some(Format::PrCommentGithub)
7814 ));
7815 assert!(matches!(
7816 parse_format_arg("pr-comment-gitlab"),
7817 Some(Format::PrCommentGitlab)
7818 ));
7819 assert!(matches!(
7820 parse_format_arg("review-github"),
7821 Some(Format::ReviewGithub)
7822 ));
7823 assert!(matches!(
7824 parse_format_arg("review-gitlab"),
7825 Some(Format::ReviewGitlab)
7826 ));
7827 assert!(matches!(parse_format_arg("badge"), Some(Format::Badge)));
7828 assert!(parse_format_arg("xml").is_none());
7829 assert!(parse_format_arg("").is_none());
7830 }
7831
7832 #[test]
7833 fn quiet_parsing_logic() {
7834 let parse = |s: &str| -> bool { s == "1" || s.eq_ignore_ascii_case("true") };
7835 assert!(parse("1"));
7836 assert!(parse("true"));
7837 assert!(parse("TRUE"));
7838 assert!(parse("True"));
7839 assert!(!parse("0"));
7840 assert!(!parse("false"));
7841 assert!(!parse("yes"));
7842 }
7843
7844 #[test]
7845 fn tracing_filter_defaults_to_warn_without_env() {
7846 assert_eq!(build_tracing_filter(None).to_string(), "warn");
7847 }
7848
7849 #[test]
7850 fn tracing_filter_respects_explicit_env_directives() {
7851 assert_eq!(build_tracing_filter(Some("info")).to_string(), "info");
7852 }
7853
7854 #[test]
7855 fn tracing_filter_treats_empty_env_as_off() {
7856 assert_eq!(build_tracing_filter(Some("")).to_string(), "off");
7857 assert_eq!(build_tracing_filter(Some(" ")).to_string(), "off");
7858 }
7859}