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;
31mod baselines;
32pub use base_worktree::canonical_root_hash;
36mod walkthrough_state;
37use fallow_engine::baseline;
38mod agent_install;
39mod baseline_gate;
40mod baseline_growth;
41mod cache_notice;
42mod check;
43mod ci;
44mod ci_template;
45mod claude_code_hint;
46mod cli_agent;
47mod cli_format;
48mod cli_hooks;
49mod cli_impact;
50mod cli_production;
51mod cli_report;
52mod cli_startup;
53pub use fallow_engine::codeowners;
54mod combined;
55mod config;
56mod count_delta;
57mod coverage;
58mod discovery_note;
59mod doctor;
60mod dupes;
61mod exit_codes;
62pub mod explain;
63mod fix;
64mod flags;
65mod flags_retirement_formats;
66mod gates;
67mod guard;
68mod health;
69mod impact;
70mod init;
71mod inspect;
72mod json_style;
73mod license;
74mod list;
75mod migrate;
76mod onboarding;
77#[cfg(test)]
78mod output_envelope;
79mod output_runtime;
80mod path_util;
81mod plugin_check;
82mod process_clock;
83mod rayon_pool;
84mod regression;
85pub mod report;
86mod requests;
87mod rule_pack;
88mod runtime_support;
89mod schema;
90mod scope_path;
91mod security;
92mod security_help;
93mod selector;
94mod setup_hooks;
95mod signal;
96mod similar_code_cli;
97mod similar_code_help;
98mod suppressions;
99mod task_matrix;
100mod telemetry;
101mod trace_chain;
102mod trace_dependency;
103mod trace_error;
104mod trace_path;
105mod type_aware_degrade;
106mod update_check;
107use fallow_engine::validate;
108use fallow_engine::vital_signs;
109mod cli_telemetry;
110mod viz;
111mod watch;
112mod write_scope;
113
114use check::{CheckOptions, IssueFilters, TraceOptions};
115pub(crate) mod error;
117use cli_agent::{AgentCli, run_agent_command};
118#[cfg(test)]
119use cli_format::parse_format_arg;
120use cli_format::{Format, FormatConfig};
121use cli_hooks::{HooksCli, run_hooks_command};
122use cli_impact::{ImpactCli, ImpactCrossRepoOpts, ImpactSortCli, dispatch_impact};
123use cli_production::{ProductionModes, resolve_production_modes};
124#[cfg(test)]
125use cli_startup::build_tracing_filter;
126use cli_startup::{
127 bare_combined_baseline_subcommand_error_message, bare_coverage_subcommand_error_message,
128 cli_bare_combined_baseline_flag, cli_global_baseline_flag, cli_has_bare_coverage_input,
129 parse_cli_args, reject_global_baseline_flags, run_pre_dispatch_checks, setup_tracing,
130 validate_inputs,
131};
132#[cfg(test)]
133use cli_telemetry::TelemetryRun;
134#[cfg(test)]
135use cli_telemetry::{fallback_failure_reason_for, telemetry_workflow_for_command};
136use cli_telemetry::{record_run_epilogue, start_telemetry_run};
137use dupes::{DupesMode, DupesOptions, DupesOverrides};
138use error::emit_error;
139use health::{HealthOptions, SortBy};
140use list::ListOptions;
141pub(crate) use runtime_support::{AnalysisKind, GroupBy};
142pub(crate) use runtime_support::{
143 ConfigLoadOptions, LoadConfigArgs, build_ownership_resolver, load_config,
144 load_config_for_analysis,
145};
146#[cfg(test)]
147use security_help::{SECURITY_UNSUPPORTED_GLOBAL_LONGS, SecurityHelpTarget};
148use security_help::{render_security_help, security_help_target};
149use similar_code_help::{render_similar_code_help, similar_code_help_target};
150
151const DEFAULT_MIN_INVOCATIONS_HOT: u64 = 100;
152
153const TOP_LEVEL_HELP_TEMPLATE: &str =
154 "{about-with-newline}\n{usage-heading} {usage}{after-help}\n\nOptions:\n{options}";
155
156macro_rules! top_level_task_cheat_sheet {
159 () => {
160 "\
161When the agent is about to...
162 delete an \"unused\" export or file fallow dead-code --trace <file>:<export>
163 prove exact TypeScript symbol consumers fallow dead-code --type-aware --symbol-impact <file>:<export-or-class.method>
164 find how one module reaches another fallow trace --path <from> <to>
165 delete an \"unused\" dependency fallow dead-code --trace-dependency <name>
166 migrate a dependency fallow trace --dependency <name> --sites
167 commit or open a PR fallow audit --base <ref>
168 read a diff before approving it fallow review --base <ref> --brief
169 prioritize refactoring fallow health --hotspots --targets
170 ask who owns code fallow health --ownership
171 check untested-but-reachable code fallow health --coverage-gaps
172 consolidate duplication fallow dupes --trace dup:<fingerprint>
173 find feature flags fallow flags
174 check architecture rules before editing fallow guard <files>
175 check cycles and boundaries after edits fallow architecture
176 surface security candidates fallow security
177 inspect a target before editing fallow inspect --file <path>
178 understand a finding fallow explain <issue-type>
179 scope a monorepo --workspace <glob> / --changed-workspaces <ref>"
180 };
181}
182
183macro_rules! top_level_core_command_groups {
184 () => {
185 "\
186Analysis:
187 dead-code Analyze unused code and dependency hygiene
188 guard Show which architecture rules apply to files before editing
189 architecture Check import cycles, boundaries and policy rules after editing
190 dupes Find copy-paste and structural code duplication
191 health Analyze complexity, maintainability, hotspots, and coverage gaps
192 flags Detect feature flag usage patterns
193 security Surface local security candidates for agent verification (opt-in)
194 similar-code Find semantic implementation overlap for verification (opt-in, local)
195 audit Review changed files for dead code, complexity, duplication, and styling
196
197Workflow:
198 watch Re-run analysis as files change
199 fix Auto-fix safe unused-code findings"
200 };
201}
202
203macro_rules! top_level_extended_command_groups {
204 () => {
205 "\
206Project inspection:
207 list List discovered files, entry points, plugins, boundaries, workspaces, and entry weight
208 inspect Inspect one file or exported symbol as a bundled evidence query
209 trace Trace a symbol's call chain (best-effort, syntactic)
210 trace-error Resolve a runtime stack trace's frames to project definitions
211 decision-surface Surface the structural decisions a change embeds (advisory)
212 workspaces Show monorepo workspace discovery diagnostics
213 explain Explain one issue type without running analysis
214 suppressions List active fallow-ignore suppression markers
215 impact Show what fallow has done for you (opt-in, local-only)
216 viz Generate an interactive HTML map of the codebase
217
218Setup and configuration:
219 doctor Diagnose project readiness without changing anything
220 init Create a fallow config, optionally with a Git hook
221 agent Wire fallow into Claude Code, Codex, or Cursor in one pass
222 audit-cache Maintain reusable audit base-snapshot caches
223 baselines Prune committed baselines to the findings that remain
224 recommend Recommend a project-tailored config for an agent to author
225 migrate Migrate knip, jscpd, or stylelint config to fallow
226 config Show the resolved config and loaded config file
227 config-schema Print the fallow config JSON Schema
228 plugin-schema Print the external plugin JSON Schema
229 plugin-check Dry-run external plugins and report what they seed
230 rule-pack Manage declarative rule packs (policy-as-code)
231 rule-pack-schema Print the rule pack JSON Schema
232 type-aware Inspect the optional TypeScript semantic companion
233
234Automation and CI:
235 ci Build PR/MR feedback envelopes
236 ci-template Print or vendor CI integration templates
237 report Re-render saved JSON as GitHub or CodeClimate output
238 hooks Install or remove fallow-managed Git and agent hooks
239 setup-hooks Deprecated: use `agent install` or `hooks install --target agent`
240
241Runtime coverage:
242 coverage Set up or analyze runtime coverage data
243 license Manage the paid-feature license
244 telemetry Manage opt-in product telemetry
245
246Reference:
247 schema Dump the CLI interface as machine-readable JSON
248 help Print this message or the help of a command"
249 };
250}
251
252const TOP_LEVEL_AFTER_HELP: &str = concat!(
253 top_level_task_cheat_sheet!(),
254 "\n\n",
255 top_level_core_command_groups!(),
256 "\n\nRun fallow --help for the complete command list."
257);
258
259const TOP_LEVEL_AFTER_LONG_HELP: &str = concat!(
260 top_level_task_cheat_sheet!(),
261 "\n\n",
262 top_level_core_command_groups!(),
263 "\n\n",
264 top_level_extended_command_groups!(),
265 "\n\n",
266 "When no command is given, fallow runs dead-code + dupes + health together.\n",
267 "Use --only/--skip to select specific analyses."
268);
269
270#[derive(Parser)]
271#[command(
272 name = "fallow",
273 about = "Codebase analyzer for TypeScript/JavaScript: unused code, circular dependencies, code duplication, complexity hotspots, and architecture boundary violations",
274 version,
275 disable_version_flag = true,
276 help_template = TOP_LEVEL_HELP_TEMPLATE,
277 after_help = TOP_LEVEL_AFTER_HELP,
278 after_long_help = TOP_LEVEL_AFTER_LONG_HELP
279)]
280struct Cli {
281 #[command(subcommand)]
282 command: Option<Command>,
283
284 #[arg(value_name = "PATH")]
287 path: Option<PathBuf>,
288
289 #[arg(
293 short = 'v',
294 visible_short_alias = 'V',
295 long = "version",
296 action = clap::ArgAction::Version
297 )]
298 version: Option<bool>,
299
300 #[arg(short, long, global = true)]
302 root: Option<PathBuf>,
303
304 #[arg(short, long, global = true)]
306 config: Option<PathBuf>,
307
308 #[arg(hide_short_help = true, long, global = true)]
310 allow_remote_extends: bool,
311
312 #[arg(
314 short,
315 long,
316 visible_alias = "output",
317 global = true,
318 default_value = "human"
319 )]
320 format: Format,
321
322 #[arg(hide_short_help = true, long, global = true)]
324 pretty: bool,
325
326 #[arg(short, long, global = true)]
328 quiet: bool,
329
330 #[arg(hide_short_help = true, long, global = true)]
332 no_cache: bool,
333
334 #[arg(hide_short_help = true, long, global = true)]
336 threads: Option<usize>,
337
338 #[arg(long, visible_alias = "base", global = true)]
343 changed_since: Option<String>,
344
345 #[arg(hide_short_help = true, long, global = true)]
351 no_package_baselines: bool,
352
353 #[arg(
358 hide_short_help = true,
359 long = "diff-file",
360 value_name = "PATH",
361 global = true
362 )]
363 diff_file: Option<PathBuf>,
364
365 #[arg(hide_short_help = true, long = "diff-stdin", global = true)]
368 diff_stdin: bool,
369
370 #[arg(
377 hide_short_help = true,
378 long = "churn-file",
379 value_name = "PATH",
380 global = true
381 )]
382 churn_file: Option<PathBuf>,
383
384 #[arg(
391 hide_short_help = true,
392 long = "max-file-size",
393 value_name = "MB",
394 global = true
395 )]
396 max_file_size: Option<u32>,
397
398 #[arg(hide_short_help = true, long, global = true)]
402 baseline: Option<PathBuf>,
403
404 #[arg(
419 hide_short_help = true,
420 long = "baseline-mode",
421 value_enum,
422 global = true
423 )]
424 baseline_mode: Option<BaselineModeArg>,
425
426 #[arg(long, global = true, value_name = "RUN_ID", hide = true)]
432 parent_run: Option<String>,
433
434 #[arg(hide_short_help = true, long, global = true)]
439 save_baseline: Option<PathBuf>,
440
441 #[arg(long, global = true)]
444 production: bool,
445
446 #[arg(
450 hide_short_help = true,
451 long = "no-production",
452 global = true,
453 conflicts_with = "production"
454 )]
455 no_production: bool,
456
457 #[arg(hide_short_help = true, long = "production-dead-code")]
459 production_dead_code: bool,
460
461 #[arg(hide_short_help = true, long = "production-health")]
463 production_health: bool,
464
465 #[arg(hide_short_help = true, long = "production-dupes")]
467 production_dupes: bool,
468
469 #[arg(short, long, global = true, value_delimiter = ',')]
473 workspace: Option<Vec<String>>,
474
475 #[arg(long, global = true, value_name = "REF")]
478 changed_workspaces: Option<String>,
479
480 #[arg(hide_short_help = true, long, global = true)]
482 group_by: Option<GroupBy>,
483
484 #[arg(
489 hide_short_help = true,
490 long,
491 global = true,
492 value_delimiter = ',',
493 value_name = "KEY",
494 requires = "group_by"
495 )]
496 group: Option<Vec<String>>,
497
498 #[arg(hide_short_help = true, long, global = true)]
500 performance: bool,
501
502 #[arg(hide_short_help = true, long, global = true)]
504 explain: bool,
505
506 #[arg(hide_short_help = true, long, global = true)]
509 explain_skipped: bool,
510
511 #[arg(hide_short_help = true, long, global = true)]
513 summary: bool,
514
515 #[arg(long, global = true)]
517 ci: bool,
518
519 #[arg(hide_short_help = true, long, global = true)]
521 fail_on_issues: bool,
522
523 #[arg(hide_short_help = true, long, global = true, value_name = "PATH")]
528 sarif_file: Option<PathBuf>,
529
530 #[arg(short = 'o', long, global = true, value_name = "PATH")]
536 output_file: Option<PathBuf>,
537
538 #[arg(
548 hide_short_help = true,
549 long = "report-path-prefix",
550 visible_alias = "annotations-path-prefix",
551 global = true,
552 value_name = "PREFIX"
553 )]
554 report_path_prefix: Option<String>,
555
556 #[arg(hide_short_help = true, long, global = true)]
562 fail_on_regression: bool,
563
564 #[arg(hide_short_help = true, long, global = true)]
578 fail_on_stale_baseline: bool,
579
580 #[arg(hide_short_help = true, long, global = true)]
595 fail_on_baseline_growth: bool,
596
597 #[arg(hide_short_help = true, long, global = true, value_name = "REF")]
602 baseline_base: Option<String>,
603
604 #[arg(hide_short_help = true, long, global = true)]
614 fail_on_parse_error: bool,
615
616 #[arg(
622 hide_short_help = true,
623 long,
624 global = true,
625 value_name = "TOLERANCE",
626 default_value = "0"
627 )]
628 tolerance: String,
629
630 #[arg(hide_short_help = true, long, global = true, value_name = "PATH")]
632 regression_baseline: Option<PathBuf>,
633
634 #[expect(
639 clippy::option_option,
640 reason = "clap pattern: None=not passed, Some(None)=flag only (write to config), Some(Some(path))=write to file"
641 )]
642 #[arg(hide_short_help = true, long, global = true, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
643 save_regression_baseline: Option<Option<String>>,
644
645 #[arg(long, value_delimiter = ',')]
647 only: Vec<AnalysisKind>,
648
649 #[arg(long, value_delimiter = ',')]
651 skip: Vec<AnalysisKind>,
652
653 #[arg(hide_short_help = true, long = "dupes-mode", global = true)]
655 dupes_mode: Option<DupesMode>,
656
657 #[arg(hide_short_help = true, long = "dupes-near", global = true)]
659 dupes_near: bool,
660
661 #[arg(hide_short_help = true, long = "dupes-threshold", global = true)]
663 dupes_threshold: Option<f64>,
664
665 #[arg(hide_short_help = true, long = "dupes-min-tokens", global = true)]
667 dupes_min_tokens: Option<usize>,
668
669 #[arg(hide_short_help = true, long = "dupes-min-lines", global = true)]
671 dupes_min_lines: Option<usize>,
672
673 #[arg(hide_short_help = true, long = "dupes-min-occurrences", global = true, value_parser = parse_min_occurrences)]
675 dupes_min_occurrences: Option<usize>,
676
677 #[arg(hide_short_help = true, long = "dupes-skip-local", global = true)]
679 dupes_skip_local: bool,
680
681 #[arg(hide_short_help = true, long = "dupes-cross-language", global = true)]
683 dupes_cross_language: bool,
684
685 #[arg(hide_short_help = true, long = "dupes-ignore-imports", global = true)]
688 dupes_ignore_imports: bool,
689
690 #[arg(
693 hide_short_help = true,
694 long = "dupes-no-ignore-imports",
695 global = true,
696 conflicts_with = "dupes_ignore_imports"
697 )]
698 dupes_no_ignore_imports: bool,
699
700 #[arg(hide_short_help = true, long = "dupes-ignore-symlinks", global = true)]
703 dupes_ignore_symlinks: bool,
704
705 #[arg(
708 hide_short_help = true,
709 long = "dupes-no-ignore-symlinks",
710 global = true,
711 conflicts_with = "dupes_ignore_symlinks"
712 )]
713 dupes_no_ignore_symlinks: bool,
714
715 #[arg(hide_short_help = true, long)]
717 score: bool,
718
719 #[arg(hide_short_help = true, long)]
721 trend: bool,
722
723 #[arg(hide_short_help = true, long, value_name = "PATH")]
726 trend_from: Option<PathBuf>,
727
728 #[expect(
732 clippy::option_option,
733 reason = "clap pattern: None=not passed, Some(None)=default path, Some(Some(path))=custom path"
734 )]
735 #[arg(hide_short_help = true, long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
736 save_snapshot: Option<Option<String>>,
737
738 #[arg(hide_short_help = true, long, value_name = "PATH")]
741 coverage: Option<PathBuf>,
742
743 #[arg(hide_short_help = true, long = "coverage-root", value_name = "PATH")]
746 coverage_root: Option<PathBuf>,
747
748 #[arg(hide_short_help = true, long = "dupes-baseline", value_name = "PATH")]
751 dupes_baseline: Option<PathBuf>,
752
753 #[arg(hide_short_help = true, long = "health-baseline", value_name = "PATH")]
756 health_baseline: Option<PathBuf>,
757
758 #[arg(hide_short_help = true, long, global = true)]
760 include_entry_exports: bool,
761
762 #[arg(hide_short_help = true, long, global = true)]
766 show_cascade: bool,
767
768 #[arg(hide_short_help = true, long, global = true)]
771 type_aware: bool,
772
773 #[arg(
776 hide_short_help = true,
777 long,
778 global = true,
779 conflicts_with = "type_aware"
780 )]
781 no_type_aware: bool,
782
783 #[arg(hide_short_help = true, long, global = true, value_name = "PATH", action = clap::ArgAction::Append)]
785 type_aware_project: Vec<PathBuf>,
786
787 #[arg(hide_short_help = true, long, global = true, value_enum)]
789 type_aware_require: Option<TypeAwareRequireArg>,
790}
791
792impl Cli {
793 const fn type_aware_override(&self) -> Option<bool> {
797 if self.no_type_aware {
798 Some(false)
799 } else if self.type_aware {
800 Some(true)
801 } else {
802 None
803 }
804 }
805
806 const fn dupes_overrides(&self) -> DupesOverrides {
808 DupesOverrides {
809 mode: self.dupes_mode,
810 near: self.dupes_near,
811 threshold: self.dupes_threshold,
812 min_tokens: self.dupes_min_tokens,
813 min_lines: self.dupes_min_lines,
814 min_occurrences: self.dupes_min_occurrences,
815 skip_local: self.dupes_skip_local,
816 ignore_symlinks: resolve_negatable_flag(
817 self.dupes_ignore_symlinks,
818 self.dupes_no_ignore_symlinks,
819 ),
820 cross_language: self.dupes_cross_language,
821 ignore_imports: resolve_negatable_flag(
822 self.dupes_ignore_imports,
823 self.dupes_no_ignore_imports,
824 ),
825 }
826 }
827}
828
829#[derive(Clone, Copy, Subcommand)]
830enum TypeAwareCli {
831 Status,
833}
834
835#[derive(Subcommand)]
836enum Command {
837 #[command(name = "dead-code", alias = "check")]
839 Check {
840 #[arg(long)]
842 unused_files: bool,
843
844 #[arg(long)]
846 unused_exports: bool,
847
848 #[arg(long)]
850 unused_deps: bool,
851
852 #[arg(long)]
854 unused_types: bool,
855
856 #[arg(long)]
858 private_type_leaks: bool,
859
860 #[arg(long)]
862 deprecated_exports_in_use: bool,
863
864 #[arg(long)]
866 unused_enum_members: bool,
867
868 #[arg(long)]
870 unused_class_members: bool,
871
872 #[arg(long)]
874 unused_store_members: bool,
875
876 #[arg(long)]
878 unprovided_injects: bool,
879
880 #[arg(long)]
882 unrendered_components: bool,
883
884 #[arg(long)]
886 unused_component_props: bool,
887
888 #[arg(long)]
890 absent_component_props: bool,
891
892 #[arg(long)]
894 unused_component_emits: bool,
895
896 #[arg(long)]
898 unused_component_inputs: bool,
899
900 #[arg(long)]
902 unused_component_outputs: bool,
903
904 #[arg(long)]
906 unused_svelte_events: bool,
907
908 #[arg(long)]
910 unused_server_actions: bool,
911
912 #[arg(long)]
914 unused_load_data_keys: bool,
915
916 #[arg(long)]
918 unresolved_imports: bool,
919
920 #[arg(long)]
922 unlisted_deps: bool,
923
924 #[arg(long)]
926 duplicate_exports: bool,
927
928 #[arg(long)]
931 circular_deps: bool,
932
933 #[arg(long)]
936 re_export_cycles: bool,
937
938 #[arg(long)]
941 package_cycles: bool,
942
943 #[arg(long)]
946 boundary_violations: bool,
947
948 #[arg(long)]
951 policy_violations: bool,
952
953 #[arg(long)]
955 stale_suppressions: bool,
956
957 #[arg(long)]
959 unused_catalog_entries: bool,
960
961 #[arg(long)]
963 empty_catalog_groups: bool,
964
965 #[arg(long)]
967 unresolved_catalog_references: bool,
968
969 #[arg(long)]
971 unused_dependency_overrides: bool,
972
973 #[arg(long)]
975 misconfigured_dependency_overrides: bool,
976
977 #[arg(long)]
979 include_dupes: bool,
980
981 #[arg(long, value_name = "FILE:EXPORT")]
983 trace: Option<String>,
984
985 #[arg(long, value_name = "PATH")]
987 trace_file: Option<String>,
988
989 #[arg(long, value_name = "PACKAGE")]
991 trace_dependency: Option<String>,
992
993 #[arg(long, value_name = "PATH")]
997 impact_closure: Option<String>,
998
999 #[arg(long, value_name = "FILE:EXPORT")]
1001 symbol_impact: Option<String>,
1002
1003 #[arg(long)]
1008 top: Option<usize>,
1009
1010 #[arg(long, value_name = "PATH")]
1014 file: Vec<std::path::PathBuf>,
1015
1016 #[arg(long = "finding-id", value_name = "ID", value_delimiter = ',')]
1021 finding_id: Vec<String>,
1022
1023 #[arg(value_name = "PATH")]
1026 path: Option<std::path::PathBuf>,
1027 },
1028
1029 Architecture {
1034 #[arg(long)]
1037 cycles: bool,
1038
1039 #[arg(long)]
1041 boundaries: bool,
1042
1043 #[arg(long)]
1045 policy: bool,
1046
1047 #[arg(long, value_name = "PATH")]
1051 file: Vec<std::path::PathBuf>,
1052
1053 #[arg(long = "finding-id", value_name = "ID", value_delimiter = ',')]
1056 finding_id: Vec<String>,
1057
1058 #[arg(value_name = "PATH")]
1061 path: Option<std::path::PathBuf>,
1062 },
1063
1064 Watch {
1066 #[arg(long)]
1068 no_clear: bool,
1069 },
1070
1071 TypeAware {
1073 #[command(subcommand)]
1074 subcommand: TypeAwareCli,
1075 },
1076
1077 #[command(override_help = doctor::HELP)]
1084 Doctor,
1085
1086 SimilarCode {
1092 #[command(subcommand)]
1093 subcommand: Option<similar_code_cli::SimilarCodeSubcommand>,
1094 #[arg(long, value_name = "0..1")]
1096 threshold: Option<f64>,
1097 #[arg(long, value_name = "N")]
1099 min_lines: Option<usize>,
1100 #[arg(long, value_name = "N")]
1102 top: Option<usize>,
1103 #[arg(long, value_name = "PATH")]
1105 file: Vec<PathBuf>,
1106
1107 #[arg(value_name = "PATH")]
1110 path: Option<PathBuf>,
1111 },
1112
1113 Inspect {
1115 #[arg(
1117 long,
1118 value_name = "PATH",
1119 conflicts_with = "symbol",
1120 required_unless_present = "symbol"
1121 )]
1122 file: Option<String>,
1123
1124 #[arg(long, value_name = "FILE:EXPORT", conflicts_with = "file")]
1126 symbol: Option<String>,
1127
1128 #[arg(long)]
1133 symbol_chain: bool,
1134
1135 #[arg(long)]
1138 churn: bool,
1139 },
1140
1141 Trace {
1156 #[arg(
1159 value_name = "FILE:SYMBOL",
1160 required_unless_present_any = ["path", "dependency"]
1161 )]
1162 symbol: Option<String>,
1163
1164 #[arg(
1171 long,
1172 value_name = "PACKAGE",
1173 conflicts_with_all = ["symbol", "path", "callees"]
1174 )]
1175 dependency: Option<String>,
1176
1177 #[arg(
1180 long,
1181 value_name = "NAME",
1182 value_delimiter = ',',
1183 requires = "dependency"
1184 )]
1185 specifier: Vec<String>,
1186
1187 #[arg(long, requires = "dependency")]
1189 sites: bool,
1190
1191 #[arg(
1194 long,
1195 value_name = "N",
1196 requires = "dependency",
1197 value_parser = clap::value_parser!(u16).range(1..=500)
1198 )]
1199 limit: Option<u16>,
1200
1201 #[arg(long, value_name = "TOKEN", requires = "dependency")]
1204 cursor: Option<String>,
1205
1206 #[arg(
1210 long,
1211 num_args = 2,
1212 value_names = ["FROM", "TO"],
1213 conflicts_with_all = ["symbol", "callers", "callees", "depth"]
1214 )]
1215 path: Vec<String>,
1216
1217 #[arg(long, requires = "path")]
1223 eager_only: bool,
1224
1225 #[arg(long)]
1228 callers: bool,
1229
1230 #[arg(long)]
1233 callees: bool,
1234
1235 #[arg(long, value_name = "N")]
1238 depth: Option<u32>,
1239 },
1240
1241 #[command(name = "trace-error")]
1260 TraceError {
1261 #[arg(value_name = "FILE")]
1265 trace_file: Option<String>,
1266 },
1267
1268 Fix {
1283 #[arg(long)]
1285 dry_run: bool,
1286
1287 #[arg(long, alias = "force")]
1289 yes: bool,
1290
1291 #[arg(long)]
1298 no_create_config: bool,
1299
1300 #[arg(value_name = "PATH")]
1304 path: Option<PathBuf>,
1305 },
1306
1307 Init {
1316 #[arg(long)]
1318 toml: bool,
1319
1320 #[arg(long, conflicts_with_all = ["toml", "hooks", "branch"])]
1322 agents: bool,
1323
1324 #[arg(long)]
1328 hooks: bool,
1329
1330 #[arg(long, requires = "hooks")]
1332 branch: Option<String>,
1333
1334 #[arg(long, conflicts_with_all = ["toml", "agents", "hooks", "branch"])]
1338 decline: bool,
1339 },
1340
1341 Hooks {
1348 #[command(subcommand)]
1349 subcommand: HooksCli,
1350 },
1351
1352 Agent {
1358 #[command(subcommand)]
1359 subcommand: AgentCli,
1360 },
1361
1362 Ci {
1364 #[command(subcommand)]
1365 subcommand: CiCli,
1366 },
1367
1368 ConfigSchema,
1370
1371 PluginSchema,
1373
1374 PluginCheck,
1376
1377 RulePackSchema,
1379
1380 RulePack {
1382 #[command(subcommand)]
1383 subcommand: RulePackCli,
1384 },
1385
1386 Guard {
1388 #[arg(required = true, num_args = 1..)]
1390 files: Vec<String>,
1391 },
1392
1393 Config {
1411 #[arg(long)]
1413 path: bool,
1414 },
1415
1416 Recommend,
1424
1425 List {
1428 #[arg(long)]
1430 entry_points: bool,
1431
1432 #[arg(long)]
1434 files: bool,
1435
1436 #[arg(long)]
1438 plugins: bool,
1439
1440 #[arg(long)]
1442 boundaries: bool,
1443
1444 #[arg(long)]
1448 workspaces: bool,
1449
1450 #[arg(long)]
1460 entry_weight: bool,
1461
1462 #[arg(value_name = "PATH")]
1465 path: Option<PathBuf>,
1466 },
1467
1468 Workspaces,
1474
1475 Dupes {
1477 #[arg(long)]
1480 mode: Option<DupesMode>,
1481
1482 #[arg(long)]
1484 near: bool,
1485
1486 #[arg(long)]
1489 min_tokens: Option<usize>,
1490
1491 #[arg(long)]
1494 min_lines: Option<usize>,
1495
1496 #[arg(long, value_parser = parse_min_occurrences)]
1501 min_occurrences: Option<usize>,
1502
1503 #[arg(long)]
1506 threshold: Option<f64>,
1507
1508 #[arg(long)]
1510 skip_local: bool,
1511
1512 #[arg(long)]
1514 cross_language: bool,
1515
1516 #[arg(long)]
1520 ignore_imports: bool,
1521
1522 #[arg(long, conflicts_with = "ignore_imports")]
1525 no_ignore_imports: bool,
1526
1527 #[arg(long)]
1532 ignore_symlinks: bool,
1533
1534 #[arg(long, conflicts_with = "ignore_symlinks")]
1537 no_ignore_symlinks: bool,
1538
1539 #[arg(long)]
1549 top: Option<usize>,
1550
1551 #[arg(long)]
1555 no_fragments: bool,
1556
1557 #[arg(long, value_name = "FILE:LINE")]
1559 trace: Option<String>,
1560
1561 #[arg(value_name = "PATH")]
1564 path: Option<PathBuf>,
1565 },
1566
1567 Health {
1573 #[arg(long)]
1575 max_cyclomatic: Option<u16>,
1576
1577 #[arg(long)]
1579 max_cognitive: Option<u16>,
1580
1581 #[arg(long)]
1585 max_crap: Option<f64>,
1586
1587 #[arg(long)]
1589 top: Option<usize>,
1590
1591 #[arg(long, default_value = "cyclomatic")]
1593 sort: SortBy,
1594
1595 #[arg(long)]
1598 complexity: bool,
1599
1600 #[arg(long)]
1607 complexity_breakdown: bool,
1608
1609 #[arg(long)]
1614 file_scores: bool,
1615
1616 #[arg(long)]
1619 coverage_gaps: bool,
1620
1621 #[arg(long)]
1624 hotspots: bool,
1625
1626 #[arg(long)]
1630 ownership: bool,
1631
1632 #[arg(long, value_name = "MODE", value_enum)]
1637 ownership_emails: Option<EmailModeArg>,
1638
1639 #[arg(long)]
1642 targets: bool,
1643
1644 #[arg(long)]
1647 type_coupling: bool,
1648
1649 #[arg(long)]
1654 css: bool,
1655
1656 #[arg(long, value_enum)]
1659 effort: Option<EffortFilter>,
1660
1661 #[arg(long)]
1664 score: bool,
1665
1666 #[arg(long, value_name = "N")]
1675 min_score: Option<f64>,
1676
1677 #[arg(long, value_name = "LEVEL", value_enum)]
1683 min_severity: Option<HealthSeverityCli>,
1684
1685 #[arg(long)]
1689 report_only: bool,
1690
1691 #[arg(long, value_name = "DURATION")]
1694 since: Option<String>,
1695
1696 #[arg(long, value_name = "N")]
1698 min_commits: Option<u32>,
1699
1700 #[expect(
1705 clippy::option_option,
1706 reason = "clap pattern: None=not passed, Some(None)=flag only, Some(Some(path))=with value"
1707 )]
1708 #[arg(long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
1709 save_snapshot: Option<Option<String>>,
1710
1711 #[arg(long)]
1715 trend: bool,
1716
1717 #[arg(long, value_name = "PATH")]
1722 trend_from: Option<PathBuf>,
1723
1724 #[arg(long, value_name = "PATH")]
1737 coverage: Option<PathBuf>,
1738
1739 #[arg(long, value_name = "PATH")]
1745 coverage_root: Option<PathBuf>,
1746
1747 #[arg(long, value_name = "PATH")]
1753 runtime_coverage: Option<PathBuf>,
1754
1755 #[arg(long, default_value_t = 100)]
1757 min_invocations_hot: u64,
1758
1759 #[arg(long, value_name = "N")]
1765 min_observation_volume: Option<u32>,
1766
1767 #[arg(long, value_name = "RATIO")]
1772 low_traffic_threshold: Option<f64>,
1773
1774 #[arg(value_name = "PATH")]
1777 path: Option<PathBuf>,
1778 },
1779
1780 Flags {
1787 #[arg(long)]
1789 top: Option<usize>,
1790
1791 #[arg(long)]
1794 retirement: bool,
1795
1796 #[arg(long = "reason", value_name = "CODE", requires = "retirement")]
1798 reasons: Vec<flags::RetirementReasonArg>,
1799
1800 #[arg(
1802 long,
1803 value_name = "KEY",
1804 requires = "retirement",
1805 default_value = "age"
1806 )]
1807 sort: flags::RetirementSortArg,
1808
1809 #[arg(
1812 long,
1813 value_name = "MODE",
1814 requires = "retirement",
1815 default_value = "blame"
1816 )]
1817 flag_age: flags::FlagAgeArg,
1818
1819 #[arg(long, value_name = "DAYS", requires = "retirement")]
1821 min_age: Option<u64>,
1822
1823 #[arg(long, value_name = "FILE", requires = "retirement")]
1827 flag_state: Option<std::path::PathBuf>,
1828
1829 #[arg(long, value_name = "DAYS", requires = "retirement")]
1832 max_flag_age: Option<u64>,
1833 },
1834
1835 Suppressions {
1845 #[arg(long, value_name = "PATH")]
1847 file: Vec<std::path::PathBuf>,
1848 },
1849
1850 Explain {
1856 #[arg(required = true, num_args = 1.., value_name = "ISSUE_TYPE")]
1858 issue_type: Vec<String>,
1859 },
1860
1861 #[command(visible_alias = "review")]
1886 Audit {
1887 #[arg(long = "production-dead-code")]
1889 production_dead_code: bool,
1890
1891 #[arg(long = "production-health")]
1893 production_health: bool,
1894
1895 #[arg(long = "production-dupes")]
1897 production_dupes: bool,
1898
1899 #[arg(long)]
1902 dead_code_baseline: Option<PathBuf>,
1903
1904 #[arg(long)]
1907 health_baseline: Option<PathBuf>,
1908
1909 #[arg(long)]
1912 dupes_baseline: Option<PathBuf>,
1913
1914 #[arg(long)]
1918 max_crap: Option<f64>,
1919
1920 #[arg(long, value_name = "PATH")]
1925 coverage: Option<PathBuf>,
1926
1927 #[arg(long, value_name = "PATH")]
1931 coverage_root: Option<PathBuf>,
1932
1933 #[arg(long = "no-css")]
1935 no_css: bool,
1936
1937 #[arg(long)]
1941 css_deep: bool,
1942
1943 #[arg(long = "no-css-deep")]
1945 no_css_deep: bool,
1946
1947 #[arg(long, value_enum)]
1953 gate: Option<AuditGateArg>,
1954
1955 #[arg(long, value_name = "PATH")]
1964 runtime_coverage: Option<PathBuf>,
1965
1966 #[arg(long, default_value_t = 100)]
1969 min_invocations_hot: u64,
1970
1971 #[arg(long, value_name = "MARKER", hide = true)]
1976 gate_marker: Option<String>,
1977
1978 #[arg(long)]
1984 brief: bool,
1985
1986 #[arg(
1991 long,
1992 value_name = "N",
1993 default_value_t = audit_decision_surface::DEFAULT_DECISION_CAP
1994 )]
1995 max_decisions: usize,
1996
1997 #[arg(long, conflicts_with_all = ["walkthrough_file", "walkthrough"])]
2005 walkthrough_guide: bool,
2006
2007 #[arg(long, value_name = "PATH")]
2017 walkthrough_file: Option<PathBuf>,
2018
2019 #[arg(long, conflicts_with_all = ["walkthrough_guide", "walkthrough_file"])]
2025 walkthrough: bool,
2026
2027 #[arg(long, value_name = "PATH")]
2033 mark_viewed: Vec<PathBuf>,
2034
2035 #[arg(long)]
2039 show_cleared: bool,
2040
2041 #[arg(long)]
2047 show_deprioritized: bool,
2048
2049 #[arg(value_name = "PATH")]
2052 path: Option<PathBuf>,
2053 },
2054
2055 AuditCache {
2057 #[command(subcommand)]
2058 subcommand: AuditCacheCli,
2059 },
2060
2061 Baselines {
2063 #[command(subcommand)]
2064 subcommand: BaselinesCli,
2065 },
2066
2067 DecisionSurface {
2082 #[arg(
2085 long,
2086 value_name = "N",
2087 default_value_t = audit_decision_surface::DEFAULT_DECISION_CAP
2088 )]
2089 max_decisions: usize,
2090 },
2091
2092 Impact {
2102 #[command(subcommand)]
2103 subcommand: Option<ImpactCli>,
2104 #[arg(long)]
2108 all: bool,
2109 #[arg(long, value_enum, default_value_t = ImpactSortCli::Recent)]
2111 sort: ImpactSortCli,
2112 #[arg(long)]
2115 limit: Option<usize>,
2116 },
2117
2118 Security {
2149 #[command(subcommand)]
2150 subcommand: Option<SecuritySubcommand>,
2151 #[arg(long, value_name = "PATH")]
2158 runtime_coverage: Option<PathBuf>,
2159 #[arg(long, default_value_t = 100)]
2162 min_invocations_hot: u64,
2163 #[arg(long, value_name = "PATH")]
2167 file: Vec<std::path::PathBuf>,
2168 #[arg(long, value_name = "MODE")]
2174 gate: Option<security::SecurityGateArg>,
2175 #[arg(long)]
2177 surface: bool,
2178
2179 #[arg(value_name = "PATH")]
2182 path: Option<PathBuf>,
2183 },
2184
2185 Report {
2190 #[arg(long, value_name = "PATH")]
2194 from: PathBuf,
2195 },
2196 Schema,
2198
2199 CiTemplate {
2206 #[command(subcommand)]
2207 subcommand: CiTemplateCli,
2208 },
2209
2210 Migrate {
2212 #[arg(long, conflicts_with = "jsonc")]
2214 toml: bool,
2215
2216 #[arg(long)]
2224 jsonc: bool,
2225
2226 #[arg(long)]
2228 dry_run: bool,
2229
2230 #[arg(long, value_name = "PATH")]
2232 from: Option<PathBuf>,
2233 },
2234
2235 License {
2242 #[command(subcommand)]
2243 subcommand: LicenseCli,
2244 },
2245
2246 Telemetry {
2254 #[command(subcommand)]
2255 subcommand: TelemetryCli,
2256 },
2257
2258 Coverage {
2263 #[command(subcommand)]
2264 subcommand: CoverageCli,
2265 },
2266
2267 SetupHooks {
2280 #[arg(long, value_enum)]
2282 agent: Option<setup_hooks::HookAgentArg>,
2283
2284 #[arg(long)]
2286 dry_run: bool,
2287
2288 #[arg(long)]
2291 force: bool,
2292
2293 #[arg(long)]
2295 user: bool,
2296
2297 #[arg(long)]
2299 gitignore_claude: bool,
2300
2301 #[arg(long)]
2305 uninstall: bool,
2306 },
2307
2308 Viz {
2310 #[arg(long = "out", value_name = "PATH")]
2312 output: Option<PathBuf>,
2313
2314 #[arg(long)]
2316 no_open: bool,
2317
2318 #[arg(long = "viz-format", default_value = "html")]
2320 viz_format: viz::VizFormat,
2321 },
2322}
2323
2324#[derive(Subcommand)]
2325enum SecuritySubcommand {
2326 Survivors {
2328 #[arg(long, value_name = "PATH")]
2330 candidates: PathBuf,
2331 #[arg(long, value_name = "PATH")]
2333 verdicts: PathBuf,
2334 #[arg(long)]
2336 require_verdict_for_each_candidate: bool,
2337 },
2338 #[command(name = "blind-spots")]
2340 BlindSpots {
2341 #[arg(long, value_name = "PATH")]
2343 file: Vec<PathBuf>,
2344 },
2345}
2346
2347#[derive(clap::Subcommand)]
2348enum BaselinesCli {
2349 Prune {
2363 #[arg(long)]
2365 check: bool,
2366
2367 #[arg(long, value_name = "PATH")]
2369 dead_code_baseline: Option<PathBuf>,
2370
2371 #[arg(long, value_name = "PATH")]
2373 health_baseline: Option<PathBuf>,
2374
2375 #[arg(long, value_name = "PATH")]
2377 dupes_baseline: Option<PathBuf>,
2378
2379 #[arg(long, value_name = "PATH")]
2382 coverage: Option<PathBuf>,
2383
2384 #[arg(long, value_name = "PATH")]
2386 coverage_root: Option<PathBuf>,
2387 },
2388}
2389
2390#[derive(clap::Subcommand)]
2391enum AuditCacheCli {
2392 Remove {
2398 #[arg(long)]
2400 dry_run: bool,
2401
2402 #[arg(long, alias = "force")]
2404 yes: bool,
2405 },
2406
2407 Prune {
2420 #[arg(long)]
2422 dry_run: bool,
2423
2424 #[arg(long, value_name = "N")]
2431 max_age_days: Option<u32>,
2432 },
2433}
2434
2435#[derive(clap::Subcommand)]
2436enum LicenseCli {
2437 Activate {
2442 #[arg(value_name = "JWT")]
2444 jwt: Option<String>,
2445
2446 #[arg(long, value_name = "PATH")]
2448 from_file: Option<PathBuf>,
2449
2450 #[arg(long, conflicts_with_all = ["jwt", "from_file"])]
2452 stdin: bool,
2453
2454 #[arg(long, requires = "email")]
2461 trial: bool,
2462
2463 #[arg(long, value_name = "ADDR")]
2465 email: Option<String>,
2466 },
2467 Status,
2469 Refresh {
2475 #[arg(long, value_name = "KEY")]
2485 api_key: Option<String>,
2486 },
2487 Deactivate,
2489}
2490
2491#[derive(Clone, Copy, clap::Subcommand)]
2492enum TelemetryCli {
2493 Status,
2495 Enable,
2497 Disable,
2499 Inspect {
2501 #[arg(long)]
2503 example: bool,
2504 },
2505}
2506
2507#[derive(clap::Subcommand)]
2508enum CiTemplateCli {
2509 Gitlab {
2511 #[arg(long, value_name = "DIR", num_args = 0..=1, default_missing_value = ".")]
2515 vendor: Option<PathBuf>,
2516
2517 #[arg(long)]
2519 force: bool,
2520 },
2521}
2522
2523#[derive(clap::Subcommand)]
2524enum CoverageCli {
2525 Setup {
2527 #[arg(short = 'y', long)]
2529 yes: bool,
2530
2531 #[arg(long)]
2533 non_interactive: bool,
2534
2535 #[arg(long)]
2537 json: bool,
2538 },
2539 Analyze {
2545 #[arg(long, value_name = "PATH", conflicts_with = "cloud")]
2549 runtime_coverage: Option<PathBuf>,
2550
2551 #[arg(long, visible_alias = "runtime-coverage-cloud")]
2553 cloud: bool,
2554
2555 #[arg(long, value_name = "KEY")]
2557 api_key: Option<String>,
2558
2559 #[arg(long, value_name = "URL")]
2561 api_endpoint: Option<String>,
2562
2563 #[arg(long, value_name = "OWNER/REPO")]
2569 repo: Option<String>,
2570
2571 #[arg(long, value_name = "ID")]
2573 project_id: Option<String>,
2574
2575 #[arg(long, value_name = "DAYS", default_value_t = 30)]
2577 coverage_period: u16,
2578
2579 #[arg(long, value_name = "ENV")]
2581 environment: Option<String>,
2582
2583 #[arg(long, value_name = "SHA")]
2585 commit_sha: Option<String>,
2586
2587 #[arg(long)]
2589 production: bool,
2590
2591 #[arg(long, default_value_t = 100)]
2593 min_invocations_hot: u64,
2594
2595 #[arg(long, value_name = "N")]
2597 min_observation_volume: Option<u32>,
2598
2599 #[arg(long, value_name = "RATIO")]
2601 low_traffic_threshold: Option<f64>,
2602
2603 #[arg(long)]
2605 top: Option<usize>,
2606
2607 #[arg(long)]
2609 blast_radius: bool,
2610
2611 #[arg(long)]
2613 importance: bool,
2614
2615 #[arg(long)]
2617 debug_unmatched: bool,
2618 },
2619 UploadInventory {
2630 #[arg(long, value_name = "KEY")]
2639 api_key: Option<String>,
2640
2641 #[arg(long, value_name = "URL")]
2646 api_endpoint: Option<String>,
2647
2648 #[arg(long, value_name = "PROJECT_ID")]
2653 project_id: Option<String>,
2654
2655 #[arg(long, value_name = "SHA")]
2660 git_sha: Option<String>,
2661
2662 #[arg(long)]
2668 allow_dirty: bool,
2669
2670 #[arg(long, value_name = "GLOB", num_args = 0..)]
2674 exclude_paths: Vec<String>,
2675
2676 #[arg(long, value_name = "PREFIX")]
2689 path_prefix: Option<String>,
2690
2691 #[arg(long)]
2693 dry_run: bool,
2694
2695 #[arg(long)]
2701 with_callers: bool,
2702
2703 #[arg(long)]
2707 ignore_upload_errors: bool,
2708 },
2709 UploadSourceMaps {
2722 #[arg(long, value_name = "PATH", default_value = "dist")]
2724 dir: PathBuf,
2725
2726 #[arg(long, value_name = "GLOB", default_value = "**/*.map")]
2728 include: String,
2729
2730 #[arg(long, value_name = "GLOB", default_value = "**/node_modules/**")]
2734 exclude: Vec<String>,
2735
2736 #[arg(long, value_name = "NAME")]
2740 repo: Option<String>,
2741
2742 #[arg(long, value_name = "SHA")]
2747 git_sha: Option<String>,
2748
2749 #[arg(long, value_name = "URL")]
2751 endpoint: Option<String>,
2752
2753 #[arg(long, value_name = "BOOL", default_value_t = true, action = clap::ArgAction::Set)]
2760 strip_path: bool,
2761
2762 #[arg(long)]
2764 dry_run: bool,
2765
2766 #[arg(long, value_name = "N", default_value_t = 4)]
2768 concurrency: usize,
2769
2770 #[arg(long)]
2772 fail_fast: bool,
2773 },
2774 UploadStaticFindings {
2781 #[arg(long, value_name = "KEY")]
2791 api_key: Option<String>,
2792
2793 #[arg(long, value_name = "URL")]
2798 api_endpoint: Option<String>,
2799
2800 #[arg(long, value_name = "PROJECT_ID")]
2805 project_id: Option<String>,
2806
2807 #[arg(long, value_name = "SHA")]
2812 git_sha: Option<String>,
2813
2814 #[arg(long)]
2820 allow_dirty: bool,
2821
2822 #[arg(long)]
2824 dry_run: bool,
2825
2826 #[arg(long)]
2830 ignore_upload_errors: bool,
2831 },
2832 ReviewPacket {
2843 #[arg(long, value_name = "KEY")]
2845 api_key: Option<String>,
2846
2847 #[arg(long, value_name = "URL")]
2849 api_endpoint: Option<String>,
2850
2851 #[arg(long, value_name = "OWNER/REPO")]
2856 repo: Option<String>,
2857
2858 #[arg(long, value_name = "ID")]
2860 project_id: Option<String>,
2861
2862 #[arg(long, value_name = "DAYS")]
2864 coverage_period: Option<u16>,
2865
2866 #[arg(long, value_name = "SHA")]
2868 commit_sha: Option<String>,
2869
2870 #[arg(long = "file", value_name = "PATH")]
2872 files: Vec<String>,
2873
2874 #[arg(long = "function", value_name = "FILE:NAME[:LINE]")]
2876 functions: Vec<String>,
2877 },
2878 DeploymentChanges {
2886 #[arg(long, value_name = "KEY")]
2888 api_key: Option<String>,
2889
2890 #[arg(long, value_name = "URL")]
2892 api_endpoint: Option<String>,
2893
2894 #[arg(long, value_name = "OWNER/REPO")]
2899 repo: Option<String>,
2900
2901 #[arg(long, value_name = "SHA")]
2903 sha: Option<String>,
2904
2905 #[arg(long, value_name = "KIND", value_parser = [
2907 "stopped", "new_not_called", "heated_up", "cooled_down", "new_called", "unchanged",
2908 ])]
2909 change: Option<String>,
2910
2911 #[arg(long, value_name = "N")]
2913 limit: Option<u16>,
2914
2915 #[arg(long, value_name = "CURSOR")]
2917 cursor: Option<String>,
2918 },
2919}
2920
2921#[derive(Subcommand)]
2922enum CiCli {
2923 PlanPrComment {
2925 #[arg(long)]
2927 body: PathBuf,
2928
2929 #[arg(long)]
2931 marker_id: String,
2932
2933 #[arg(long)]
2935 clean: bool,
2936
2937 #[arg(long)]
2939 existing_comment_id: Option<String>,
2940
2941 #[arg(long)]
2943 existing_body: Option<PathBuf>,
2944 },
2945
2946 PostPrComment {
2948 #[arg(long, value_enum)]
2950 provider: CiProviderArg,
2951
2952 #[arg(long)]
2954 pr: Option<String>,
2955
2956 #[arg(long)]
2958 mr: Option<String>,
2959
2960 #[arg(long)]
2962 body: PathBuf,
2963
2964 #[arg(long)]
2966 envelope: Option<PathBuf>,
2967
2968 #[arg(long)]
2970 marker_id: String,
2971
2972 #[arg(long)]
2974 clean: bool,
2975
2976 #[arg(long)]
2978 repo: Option<String>,
2979
2980 #[arg(long = "project-id")]
2982 project_id: Option<String>,
2983
2984 #[arg(long = "api-url")]
2986 api_url: Option<String>,
2987
2988 #[arg(long)]
2990 dry_run: bool,
2991 },
2992
2993 PostReview {
2995 #[arg(long, value_enum)]
2997 provider: CiProviderArg,
2998
2999 #[arg(long)]
3001 pr: Option<String>,
3002
3003 #[arg(long)]
3005 mr: Option<String>,
3006
3007 #[arg(long)]
3009 envelope: PathBuf,
3010
3011 #[arg(long)]
3013 repo: Option<String>,
3014
3015 #[arg(long = "project-id")]
3017 project_id: Option<String>,
3018
3019 #[arg(long = "api-url")]
3021 api_url: Option<String>,
3022
3023 #[arg(long)]
3025 dry_run: bool,
3026 },
3027
3028 PostCheckRun {
3030 #[arg(long, value_enum)]
3032 provider: CiProviderArg,
3033
3034 #[arg(long)]
3036 decision: PathBuf,
3037
3038 #[arg(long)]
3040 repo: String,
3041
3042 #[arg(long = "head-sha")]
3044 head_sha: String,
3045
3046 #[arg(long = "api-url")]
3048 api_url: Option<String>,
3049
3050 #[arg(long = "split-gates")]
3052 split_gates: bool,
3053
3054 #[arg(long)]
3056 dry_run: bool,
3057 },
3058
3059 ReconcileReview {
3061 #[arg(long, value_enum)]
3063 provider: CiProviderArg,
3064
3065 #[arg(long)]
3067 pr: Option<String>,
3068
3069 #[arg(long)]
3071 mr: Option<String>,
3072
3073 #[arg(long)]
3075 envelope: PathBuf,
3076
3077 #[arg(long)]
3079 repo: Option<String>,
3080
3081 #[arg(long = "project-id")]
3083 project_id: Option<String>,
3084
3085 #[arg(long = "api-url")]
3087 api_url: Option<String>,
3088
3089 #[arg(long)]
3091 dry_run: bool,
3092 },
3093}
3094
3095#[derive(Subcommand)]
3096enum RulePackCli {
3097 Init {
3099 name: Option<String>,
3101
3102 #[arg(long, default_value = "starter")]
3104 template: String,
3105
3106 #[arg(long, default_value = "rule-packs")]
3108 dir: String,
3109
3110 #[arg(long)]
3112 no_config: bool,
3113 },
3114
3115 List,
3117
3118 Test {
3120 pack: Option<PathBuf>,
3122 },
3123
3124 Schema,
3126}
3127
3128#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
3130pub enum BaselineModeArg {
3131 #[default]
3133 Count,
3134 Identity,
3137}
3138
3139impl From<BaselineModeArg> for fallow_engine::baseline::HealthBaselineMode {
3140 fn from(value: BaselineModeArg) -> Self {
3141 match value {
3142 BaselineModeArg::Count => Self::Count,
3143 BaselineModeArg::Identity => Self::Identity,
3144 }
3145 }
3146}
3147
3148#[derive(Clone, Copy, Debug, clap::ValueEnum)]
3149enum CiProviderArg {
3150 Github,
3151 Gitlab,
3152}
3153
3154#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
3156enum TypeAwareRequireArg {
3157 BestEffort,
3159 Complete,
3161}
3162
3163impl From<TypeAwareRequireArg> for fallow_config::TypeAwareRequire {
3164 fn from(value: TypeAwareRequireArg) -> Self {
3165 match value {
3166 TypeAwareRequireArg::BestEffort => Self::BestEffort,
3167 TypeAwareRequireArg::Complete => Self::Complete,
3168 }
3169 }
3170}
3171
3172#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
3174pub enum EffortFilter {
3175 Low,
3176 Medium,
3177 High,
3178}
3179
3180impl EffortFilter {
3181 const fn to_estimate(self) -> fallow_output::EffortEstimate {
3183 match self {
3184 Self::Low => fallow_output::EffortEstimate::Low,
3185 Self::Medium => fallow_output::EffortEstimate::Medium,
3186 Self::High => fallow_output::EffortEstimate::High,
3187 }
3188 }
3189}
3190
3191#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
3193pub enum HealthSeverityCli {
3194 Moderate,
3195 High,
3196 Critical,
3197}
3198
3199impl HealthSeverityCli {
3200 const fn to_health_severity(self) -> fallow_output::FindingSeverity {
3202 match self {
3203 Self::Moderate => fallow_output::FindingSeverity::Moderate,
3204 Self::High => fallow_output::FindingSeverity::High,
3205 Self::Critical => fallow_output::FindingSeverity::Critical,
3206 }
3207 }
3208}
3209
3210#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
3216pub enum EmailModeArg {
3217 Raw,
3219 Handle,
3221 Anonymized,
3223 #[value(hide = true)]
3225 Hash,
3226}
3227
3228impl EmailModeArg {
3229 const fn to_config(self) -> fallow_config::EmailMode {
3231 match self {
3232 Self::Raw => fallow_config::EmailMode::Raw,
3233 Self::Handle => fallow_config::EmailMode::Handle,
3234 Self::Anonymized => fallow_config::EmailMode::Anonymized,
3235 Self::Hash => fallow_config::EmailMode::Hash,
3236 }
3237 }
3238}
3239
3240#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
3242pub enum AuditGateArg {
3243 NewOnly,
3245 All,
3247}
3248
3249impl From<AuditGateArg> for fallow_config::AuditGate {
3250 fn from(value: AuditGateArg) -> Self {
3251 match value {
3252 AuditGateArg::NewOnly => Self::NewOnly,
3253 AuditGateArg::All => Self::All,
3254 }
3255 }
3256}
3257
3258fn parse_min_occurrences(s: &str) -> Result<usize, String> {
3262 let value: usize = s
3263 .parse()
3264 .map_err(|_| format!("`{s}` is not a non-negative integer"))?;
3265 if value < 2 {
3266 return Err(format!(
3267 "must be at least 2 (got {value}); a single occurrence isn't a duplicate"
3268 ));
3269 }
3270 Ok(value)
3271}
3272
3273fn resolve_audit_baseline_path(
3279 root: &std::path::Path,
3280 cli: Option<&std::path::Path>,
3281 config: Option<&str>,
3282) -> Option<PathBuf> {
3283 let path = cli.map(std::path::Path::to_path_buf).or_else(|| {
3284 config.map(|p| {
3285 let path = PathBuf::from(p);
3286 if path_util::is_absolute_path_any_platform(&path) {
3287 path
3288 } else {
3289 root.join(path)
3290 }
3291 })
3292 })?;
3293 if path_util::is_absolute_path_any_platform(&path) {
3294 Some(path)
3295 } else {
3296 Some(root.join(path))
3297 }
3298}
3299
3300fn emit_known_failure(
3301 message: &str,
3302 exit_code: u8,
3303 output: fallow_config::OutputFormat,
3304 reason: telemetry::FailureReason,
3305) -> ExitCode {
3306 telemetry::note_failure_reason(reason);
3307 emit_error(message, exit_code, output)
3308}
3309
3310fn emit_known_failure_with_style(
3311 message: &str,
3312 exit_code: u8,
3313 output: fallow_config::OutputFormat,
3314 json_style: json_style::JsonStyle,
3315 reason: telemetry::FailureReason,
3316) -> ExitCode {
3317 telemetry::note_failure_reason(reason);
3318 error::emit_error_with_style(message, exit_code, output, json_style)
3319}
3320
3321fn unsupported_security_global(cli: &Cli) -> Option<&'static str> {
3322 if cli.baseline.is_some() {
3323 Some("--baseline")
3324 } else if cli.save_baseline.is_some() {
3325 Some("--save-baseline")
3326 } else if cli.fail_on_stale_baseline {
3327 Some("--fail-on-stale-baseline")
3328 } else if cli.fail_on_baseline_growth {
3329 Some("--fail-on-baseline-growth")
3330 } else if cli.baseline_base.is_some() {
3331 Some("--baseline-base")
3332 } else if cli.fail_on_parse_error {
3333 Some("--fail-on-parse-error")
3334 } else if cli.production {
3335 Some("--production")
3336 } else if cli.no_production {
3337 Some("--no-production")
3338 } else if cli.no_package_baselines {
3339 Some("--no-package-baselines")
3340 } else if cli.group_by.is_some() {
3341 Some("--group-by")
3342 } else if cli.performance {
3343 Some("--performance")
3344 } else if cli.explain_skipped {
3345 Some("--explain-skipped")
3346 } else if cli.fail_on_regression {
3347 Some("--fail-on-regression")
3348 } else if cli.regression_baseline.is_some() {
3349 Some("--regression-baseline")
3350 } else if cli.save_regression_baseline.is_some() {
3351 Some("--save-regression-baseline")
3352 } else if let Some(flag) = cli.dupes_overrides().first_global_flag() {
3353 Some(flag)
3354 } else if cli.include_entry_exports {
3355 Some("--include-entry-exports")
3356 } else if cli.show_cascade {
3357 Some("--show-cascade")
3358 } else {
3359 None
3360 }
3361}
3362
3363struct DispatchContext<'a> {
3364 cli: &'a Cli,
3365 root: &'a std::path::Path,
3366 output: fallow_config::OutputFormat,
3367 quiet: bool,
3368 fail_on_issues: bool,
3369 json_style: json_style::JsonStyle,
3370 threads: usize,
3371 tolerance: regression::Tolerance,
3372 save_regression_file: Option<&'a std::path::PathBuf>,
3373 save_to_config: bool,
3374}
3375
3376impl DispatchContext<'_> {
3377 fn production_modes(
3378 &self,
3379 dead_code: bool,
3380 health: bool,
3381 dupes: bool,
3382 ) -> Result<ProductionModes, ExitCode> {
3383 resolve_production_modes(self.cli, self.root, self.output, dead_code, health, dupes)
3384 }
3385
3386 fn production_for(
3387 &self,
3388 analysis: fallow_config::ProductionAnalysis,
3389 ) -> Result<bool, ExitCode> {
3390 self.production_modes(false, false, false)
3391 .map(|modes| modes.for_analysis(analysis))
3392 }
3393
3394 fn growth_flags(&self) -> baseline_growth::GrowthFlags<'_> {
3395 baseline_growth::GrowthFlags {
3396 enabled: self.cli.fail_on_baseline_growth,
3397 base: self.cli.baseline_base.as_deref(),
3398 changed_since: self.cli.changed_since.as_deref(),
3399 }
3400 }
3401
3402 fn growth_ctx(
3403 &self,
3404 owner: baseline_growth::GrowthOwner,
3405 ) -> baseline_growth::GrowthContext<'_> {
3406 baseline_growth::GrowthContext {
3407 root: self.root,
3408 flags: self.growth_flags(),
3409 owner,
3410 output: self.output,
3411 json_style: self.json_style,
3412 }
3413 }
3414
3415 fn regression_opts(&self, scoped: bool) -> regression::RegressionOpts<'_> {
3416 regression::RegressionOpts {
3417 fail_on_regression: self.cli.fail_on_regression,
3418 tolerance: self.tolerance,
3419 regression_baseline_file: self.cli.regression_baseline.as_deref(),
3420 save_target: if let Some(path) = self.save_regression_file {
3421 regression::SaveRegressionTarget::File(path)
3422 } else if self.save_to_config {
3423 regression::SaveRegressionTarget::Config
3424 } else {
3425 regression::SaveRegressionTarget::None
3426 },
3427 scoped,
3428 quiet: self.quiet,
3429 output: self.output,
3430 }
3431 }
3432}
3433
3434#[cfg(unix)]
3449fn signal_test_helper() -> ExitCode {
3450 use std::io::Write as _;
3451 use std::process::Command;
3452
3453 if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER_GRACEFUL").is_some() {
3454 signal::set_graceful_mode();
3455 }
3456
3457 let mut command = Command::new("sleep");
3458 command.arg("30");
3459 let child = match signal::ScopedChild::spawn(&mut command) {
3460 Ok(c) => c,
3461 Err(err) => {
3462 let _ = writeln!(std::io::stderr(), "spawn sleep failed: {err}");
3463 return ExitCode::from(2);
3464 }
3465 };
3466 let pid = child.id();
3467 let stdout = std::io::stdout();
3468 let mut lock = stdout.lock();
3469 let _ = writeln!(lock, "{pid}");
3470 let _ = lock.flush();
3471 drop(lock);
3472 let _ = child.wait_with_output();
3473 if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER_GRACEFUL").is_some() {
3474 return ExitCode::SUCCESS;
3475 }
3476 std::thread::sleep(std::time::Duration::from_secs(5));
3477 ExitCode::SUCCESS
3478}
3479
3480#[cfg(not(unix))]
3481fn signal_test_helper() -> ExitCode {
3482 ExitCode::from(2)
3483}
3484
3485fn install_spawn_hooks() {
3486 fallow_engine::churn::set_spawn_hook(signal::scoped_child::output);
3487 fallow_engine::changed_files::set_spawn_hook(signal::scoped_child::output);
3488}
3489
3490fn install_signal_handlers() {
3491 if let Err(err) = signal::install_handlers() {
3492 use std::io::Write as _;
3493 let stderr = std::io::stderr();
3494 let mut lock = stderr.lock();
3495 let _ = writeln!(lock, "fallow: failed to install signal handlers: {err}");
3496 }
3497}
3498
3499fn redirect_report_to_file(
3504 path: &std::path::Path,
3505 output: fallow_config::OutputFormat,
3506) -> Result<(), ExitCode> {
3507 match fallow_engine::write_guard::create_file(
3508 path,
3509 fallow_engine::write_guard::WriteTarget::Path,
3510 ) {
3511 Ok(file) => {
3512 report::sink::set_file_sink(file);
3513 colored::control::set_override(false);
3514 Ok(())
3515 }
3516 Err(e) if e.is_directory() => Err(emit_error(
3517 &format!(
3518 "failed to create the directory of {} for --output-file: {e}",
3519 path.display()
3520 ),
3521 2,
3522 output,
3523 )),
3524 Err(e) => Err(emit_error(
3525 &format!("failed to open {} for --output-file: {e}", path.display()),
3526 2,
3527 output,
3528 )),
3529 }
3530}
3531
3532fn finalize_report_file(
3535 path: &std::path::Path,
3536 quiet: bool,
3537 output: fallow_config::OutputFormat,
3538) -> Result<(), ExitCode> {
3539 if let Err(e) = report::sink::flush() {
3540 return Err(emit_error(
3541 &format!("failed to write {}: {e}", path.display()),
3542 2,
3543 output,
3544 ));
3545 }
3546 if !quiet && report::sink::wrote() {
3550 eprintln!("Report written to {}", path.display());
3551 }
3552 Ok(())
3553}
3554
3555pub fn run() -> ExitCode {
3563 report::sink::finish_stdout(run_command())
3564}
3565
3566fn run_command() -> ExitCode {
3567 process_clock::mark_process_start();
3568 install_signal_handlers();
3569 install_spawn_hooks();
3570
3571 if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER").is_some() {
3572 return signal_test_helper();
3573 }
3574
3575 let (mut cli, fmt) = match parse_cli_args() {
3576 Ok(parsed) => parsed,
3577 Err(code) => return code,
3578 };
3579 if cli.pretty && !fmt.payload_is_json {
3580 eprintln!(
3581 "Error: --pretty requires JSON output. Use --format json --pretty, or remove --pretty."
3582 );
3583 return ExitCode::from(2);
3584 }
3585
3586 if let Some(code) = reject_global_baseline_flags(&cli, &fmt) {
3587 return code;
3588 }
3589
3590 if let Some(code) = run_schema_command_if_requested(&cli, fmt.json_style) {
3591 return code;
3592 }
3593
3594 if let Some(code) = run_telemetry_command_if_requested(&mut cli, fmt.output, fmt.json_style) {
3595 return code;
3596 }
3597 if let Some(code) = run_doctor_command_if_requested(&cli, &fmt) {
3598 return code;
3599 }
3600 if is_impact_statusline(&cli) {
3601 let (root, _) = match validate_inputs(&cli, fmt.output, fmt.json_style) {
3602 Ok(validated) => validated,
3603 Err(code) => return code,
3604 };
3605 return cli_impact::render_impact_statusline(&root);
3606 }
3607 let telemetry_run = start_telemetry_run(&cli, &fmt);
3608
3609 let (root, threads) = match validate_inputs(&cli, fmt.output, fmt.json_style) {
3610 Ok(v) => v,
3611 Err(code) => {
3612 return record_run_epilogue(telemetry_run, code, None, cli.parent_run.as_deref(), None);
3613 }
3614 };
3615
3616 let FormatConfig {
3617 output,
3618 payload_is_json: _,
3619 quiet,
3620 fail_on_issues,
3621 json_style,
3622 } = fmt;
3623
3624 let tolerance =
3625 match run_pre_dispatch_checks(&cli, &root, output, json_style, quiet, telemetry_run) {
3626 Ok(tolerance) => tolerance,
3627 Err(code) => return code,
3628 };
3629
3630 if let Some(note) = write_scope::default_cache_dir_note(&cli, &root) {
3631 eprintln!("{note}");
3632 cli.no_cache = true;
3633 }
3634
3635 let (save_regression_file, save_to_config) = regression_save_targets(&cli);
3636
3637 let hint_root = (!matches!(
3641 cli.command,
3642 Some(Command::Agent { .. } | Command::Hooks { .. } | Command::SetupHooks { .. })
3643 ))
3644 .then_some(root.as_path());
3645 let command = cli.command.take();
3646 process_clock::record_startup();
3647 let dispatch = DispatchContext {
3648 cli: &cli,
3649 root: &root,
3650 output,
3651 quiet,
3652 fail_on_issues,
3653 json_style,
3654 threads,
3655 tolerance,
3656 save_regression_file: save_regression_file.as_ref(),
3657 save_to_config,
3658 };
3659 let exit_code = match dispatch_and_finalize(&dispatch, command) {
3660 Ok(code) => code,
3661 Err(code) => return code,
3662 };
3663 record_run_epilogue(
3664 telemetry_run,
3665 exit_code,
3666 None,
3667 cli.parent_run.as_deref(),
3668 hint_root,
3669 )
3670}
3671
3672#[doc(hidden)]
3676pub fn benchmark_fix_dry_run(root: &Path, threads: usize) -> (ExitCode, usize) {
3677 let config_path = None;
3678 fix::run_fix_with_count(&fix::FixOptions {
3679 root,
3680 config_path: &config_path,
3681 output: fallow_config::OutputFormat::Json,
3682 json_style: json_style::JsonStyle::Compact,
3683 no_cache: true,
3684 threads,
3685 quiet: true,
3686 emit_output: false,
3687 allow_remote_extends: false,
3688 dry_run: true,
3689 yes: false,
3690 production: false,
3691 no_create_config: true,
3692 type_aware: None,
3693 type_aware_projects: &[],
3694 type_aware_require: None,
3695 scope: None,
3696 show_cascade: false,
3697 })
3698}
3699
3700#[doc(hidden)]
3703pub use audit::AuditReviewBenchmarkCorpus;
3704
3705#[doc(hidden)]
3708pub fn create_audit_review_benchmark_corpus(
3709 root: &Path,
3710 changed_files: &[PathBuf],
3711 threads: usize,
3712) -> Result<AuditReviewBenchmarkCorpus, ExitCode> {
3713 audit::create_audit_review_benchmark_corpus(root, changed_files, threads)
3714}
3715
3716#[doc(hidden)]
3719pub fn benchmark_audit_review_brief_many_changed_files_json(
3720 corpus: &mut AuditReviewBenchmarkCorpus,
3721) -> (ExitCode, usize, usize, usize, usize, usize) {
3722 match audit::benchmark_audit_review_brief_many_changed_files_json(corpus) {
3723 Ok(result) => (
3724 ExitCode::SUCCESS,
3725 result.introduced_count,
3726 result.inherited_count,
3727 result.public_api_added_count,
3728 result.decision_count,
3729 result.rendered_bytes,
3730 ),
3731 Err(code) => (code, 0, 0, 0, 0, 0),
3732 }
3733}
3734
3735#[doc(hidden)]
3736pub use inspect::InspectBenchmarkCorpus;
3737
3738#[doc(hidden)]
3741pub fn create_inspect_benchmark_corpus(root: &Path, threads: usize) -> InspectBenchmarkCorpus {
3742 inspect::create_inspect_benchmark_corpus(root, threads)
3743}
3744
3745#[doc(hidden)]
3748pub fn benchmark_inspect_file_evidence_bundle_json(
3749 root: &Path,
3750 threads: usize,
3751 corpus: &InspectBenchmarkCorpus,
3752) -> (ExitCode, usize, usize) {
3753 match inspect::benchmark_inspect_file_evidence_bundle_json(root, threads, corpus) {
3754 Ok((child_call_count, rendered_bytes)) => {
3755 (ExitCode::SUCCESS, child_call_count, rendered_bytes)
3756 }
3757 Err(_) => (ExitCode::from(2), 0, 0),
3758 }
3759}
3760
3761#[doc(hidden)]
3764pub fn benchmark_dead_code_json(root: &Path, threads: usize) -> (ExitCode, usize, usize) {
3765 match check::benchmark_dead_code_json(root, threads) {
3766 Ok((issue_count, rendered_bytes)) => (ExitCode::SUCCESS, issue_count, rendered_bytes),
3767 Err(code) => (code, 0, 0),
3768 }
3769}
3770
3771#[doc(hidden)]
3774pub fn benchmark_security_json(root: &Path, threads: usize) -> (ExitCode, usize, usize) {
3775 match security::benchmark_security_json(root, threads) {
3776 Ok((finding_count, rendered_bytes)) => (ExitCode::SUCCESS, finding_count, rendered_bytes),
3777 Err(code) => (code, 0, 0),
3778 }
3779}
3780
3781#[doc(hidden)]
3782pub use security::{SecurityBlindSpotsBenchmarkResult, SecuritySurvivorsBenchmarkCorpus};
3783
3784#[doc(hidden)]
3787pub fn create_security_survivors_benchmark_corpus(
3788 root: &Path,
3789 threads: usize,
3790) -> Result<SecuritySurvivorsBenchmarkCorpus, ExitCode> {
3791 security::create_security_survivors_benchmark_corpus(root, threads)
3792}
3793
3794#[doc(hidden)]
3797pub fn benchmark_security_survivors_json(
3798 corpus: &SecuritySurvivorsBenchmarkCorpus,
3799) -> (ExitCode, usize, usize, usize, usize, usize) {
3800 match security::benchmark_security_survivors_json(corpus) {
3801 Ok((survivors, dismissed, needs_human_review, unverdicted, rendered_bytes)) => (
3802 ExitCode::SUCCESS,
3803 survivors,
3804 dismissed,
3805 needs_human_review,
3806 unverdicted,
3807 rendered_bytes,
3808 ),
3809 Err(_) => (ExitCode::from(2), 0, 0, 0, 0, 0),
3810 }
3811}
3812
3813#[doc(hidden)]
3816pub fn benchmark_security_blind_spots_json(
3817 root: &Path,
3818 diagnostics: &[fallow_types::results::SecurityUnresolvedCalleeDiagnostic],
3819) -> SecurityBlindSpotsBenchmarkResult {
3820 security::benchmark_security_blind_spots_json(root, diagnostics)
3821}
3822
3823#[doc(hidden)]
3826pub fn benchmark_list_json(root: &Path, threads: usize) -> (ExitCode, usize, usize, usize, usize) {
3827 match list::benchmark_list_json(root, threads) {
3828 Ok((file_count, entry_point_count, workspace_count, rendered_bytes)) => (
3829 ExitCode::SUCCESS,
3830 file_count,
3831 entry_point_count,
3832 workspace_count,
3833 rendered_bytes,
3834 ),
3835 Err(code) => (code, 0, 0, 0, 0),
3836 }
3837}
3838
3839#[doc(hidden)]
3842pub fn benchmark_list_boundaries_json(
3843 root: &Path,
3844 threads: usize,
3845) -> (ExitCode, usize, usize, usize, usize) {
3846 match list::benchmark_list_boundaries_json(root, threads) {
3847 Ok((zone_count, rule_count, matched_file_count, rendered_bytes)) => (
3848 ExitCode::SUCCESS,
3849 zone_count,
3850 rule_count,
3851 matched_file_count,
3852 rendered_bytes,
3853 ),
3854 Err(code) => (code, 0, 0, 0, 0),
3855 }
3856}
3857
3858#[doc(hidden)]
3861pub use watch::WatchFilterBenchmarkGlobalGitignore;
3862
3863#[doc(hidden)]
3866pub fn create_watch_filter_benchmark_global_gitignore() -> WatchFilterBenchmarkGlobalGitignore {
3867 watch::create_benchmark_global_gitignore()
3868}
3869
3870#[doc(hidden)]
3873pub fn benchmark_watch_filter_initialization(
3874 config: &fallow_config::ResolvedConfig,
3875 global_gitignore: &WatchFilterBenchmarkGlobalGitignore,
3876) -> (usize, usize) {
3877 watch::benchmark_filter_initialization(config, global_gitignore)
3878}
3879
3880#[doc(hidden)]
3883pub fn benchmark_viz_html(root: &Path, threads: usize) -> (ExitCode, usize, usize, usize) {
3884 match viz::benchmark_viz_html(root, threads) {
3885 Ok((file_count, edge_count, rendered_bytes)) => {
3886 (ExitCode::SUCCESS, file_count, edge_count, rendered_bytes)
3887 }
3888 Err(code) => (code, 0, 0, 0),
3889 }
3890}
3891
3892#[doc(hidden)]
3895pub fn benchmark_rule_pack_test_json(root: &Path, threads: usize) -> (ExitCode, usize, usize) {
3896 match rule_pack::benchmark_rule_pack_test_json(root, threads) {
3897 Ok((finding_count, rendered_bytes)) => (ExitCode::SUCCESS, finding_count, rendered_bytes),
3898 Err(code) => (code, 0, 0),
3899 }
3900}
3901
3902#[doc(hidden)]
3905pub fn benchmark_recommend_json(root: &Path) -> (ExitCode, usize, usize, bool, usize) {
3906 match onboarding::benchmark_recommend_json(root) {
3907 Ok((decision_count, framework_count, heterogeneous, rendered_bytes)) => (
3908 ExitCode::SUCCESS,
3909 decision_count,
3910 framework_count,
3911 heterogeneous,
3912 rendered_bytes,
3913 ),
3914 Err(_) => (ExitCode::from(2), 0, 0, false, 0),
3915 }
3916}
3917
3918#[doc(hidden)]
3921pub fn benchmark_runtime_coverage_analyze_json(
3922 root: &Path,
3923 runtime_coverage_path: &Path,
3924 response_bytes: &[u8],
3925 threads: usize,
3926) -> (ExitCode, usize, usize, usize, String) {
3927 match coverage::benchmark_local_json(root, runtime_coverage_path, response_bytes, threads) {
3928 Ok((finding_count, hot_path_count, request_bytes, rendered)) => (
3929 ExitCode::SUCCESS,
3930 finding_count,
3931 hot_path_count,
3932 request_bytes,
3933 rendered,
3934 ),
3935 Err(code) => (code, 0, 0, 0, String::new()),
3936 }
3937}
3938
3939fn is_impact_statusline(cli: &Cli) -> bool {
3942 matches!(
3943 cli.command.as_ref(),
3944 Some(Command::Impact {
3945 subcommand: Some(ImpactCli::Statusline),
3946 all: false,
3947 ..
3948 })
3949 )
3950}
3951
3952fn dispatch_and_finalize(
3956 dispatch: &DispatchContext<'_>,
3957 command: Option<Command>,
3958) -> Result<ExitCode, ExitCode> {
3959 let cli = dispatch.cli;
3960 let output = dispatch.output;
3961 let quiet = dispatch.quiet;
3962
3963 if let Some(path) = cli.output_file.as_deref()
3966 && let Err(code) = redirect_report_to_file(path, output)
3967 {
3968 return Err(code);
3969 }
3970
3971 let exit_code = if command.is_some() && cli_has_bare_coverage_input(cli) {
3972 emit_error(bare_coverage_subcommand_error_message(), 2, output)
3973 } else if command.is_some()
3974 && let Some(flag) = cli_bare_combined_baseline_flag(cli)
3975 {
3976 emit_error(
3977 &bare_combined_baseline_subcommand_error_message(flag),
3978 2,
3979 output,
3980 )
3981 } else {
3982 match command {
3983 None => dispatch_bare_command(dispatch),
3984 Some(cmd) => dispatch_subcommand(cmd, dispatch),
3985 }
3986 };
3987
3988 if let Some(path) = cli.output_file.as_deref()
3989 && let Err(code) = finalize_report_file(path, quiet, output)
3990 {
3991 return Err(code);
3992 }
3993 Ok(exit_code)
3994}
3995
3996fn run_telemetry_command_if_requested(
3997 cli: &mut Cli,
3998 output: fallow_config::OutputFormat,
3999 json_style: json_style::JsonStyle,
4000) -> Option<ExitCode> {
4001 if matches!(cli.command, Some(Command::Telemetry { .. }))
4002 && let Some(Command::Telemetry { subcommand }) = cli.command.take()
4003 {
4004 return Some(telemetry::run(
4005 map_telemetry_subcommand(subcommand),
4006 output,
4007 json_style,
4008 ));
4009 }
4010 None
4011}
4012
4013fn run_doctor_command_if_requested(cli: &Cli, format: &FormatConfig) -> Option<ExitCode> {
4018 if !matches!(cli.command, Some(Command::Doctor)) {
4019 return None;
4020 }
4021
4022 if let Some(flag) = unsupported_doctor_option(cli) {
4023 let message = if flag == "--output-file" {
4024 "--output-file is not valid with `fallow doctor`; doctor is read-only and writes its report to stdout".to_string()
4025 } else {
4026 format!("{flag} is not valid with `fallow doctor`.")
4027 };
4028 return Some(crate::error::emit_error_with_style(
4029 &message,
4030 2,
4031 format.output,
4032 format.json_style,
4033 ));
4034 }
4035 if let Err(code) = doctor::validate_output(format.output, format.json_style) {
4036 return Some(code);
4037 }
4038
4039 let root = cli.root.clone().unwrap_or_else(|| {
4040 std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
4041 });
4042 let config_path = cli.config.as_ref().map(|path| {
4043 if path_util::is_absolute_path_any_platform(path) {
4044 path.clone()
4045 } else {
4046 root.join(path)
4047 }
4048 });
4049 let report = doctor::collect_report(&root, config_path.as_deref());
4050 Some(doctor::render_report(
4051 &report,
4052 format.output,
4053 format.json_style,
4054 ))
4055}
4056
4057fn unsupported_doctor_option(cli: &Cli) -> Option<&'static str> {
4061 let dupes_flag = cli.dupes_overrides().first_global_flag();
4062 [
4063 (cli.allow_remote_extends, "--allow-remote-extends"),
4064 (cli.no_cache, "--no-cache"),
4065 (cli.threads.is_some(), "--threads"),
4066 (cli.changed_since.is_some(), "--changed-since"),
4067 (cli.diff_file.is_some(), "--diff-file"),
4068 (cli.diff_stdin, "--diff-stdin"),
4069 (cli.churn_file.is_some(), "--churn-file"),
4070 (cli.max_file_size.is_some(), "--max-file-size"),
4071 (cli.baseline.is_some(), "--baseline"),
4072 (cli.baseline_mode.is_some(), "--baseline-mode"),
4073 (cli.parent_run.is_some(), "--parent-run"),
4074 (cli.save_baseline.is_some(), "--save-baseline"),
4075 (cli.production, "--production"),
4076 (cli.no_production, "--no-production"),
4077 (cli.no_package_baselines, "--no-package-baselines"),
4078 (cli.production_dead_code, "--production-dead-code"),
4079 (cli.production_health, "--production-health"),
4080 (cli.production_dupes, "--production-dupes"),
4081 (cli.workspace.is_some(), "--workspace"),
4082 (cli.changed_workspaces.is_some(), "--changed-workspaces"),
4083 (cli.group_by.is_some(), "--group-by"),
4084 (cli.performance, "--performance"),
4085 (cli.explain, "--explain"),
4086 (cli.explain_skipped, "--explain-skipped"),
4087 (cli.summary, "--summary"),
4088 (cli.ci, "--ci"),
4089 (cli.fail_on_issues, "--fail-on-issues"),
4090 (cli.sarif_file.is_some(), "--sarif-file"),
4091 (cli.output_file.is_some(), "--output-file"),
4092 (cli.report_path_prefix.is_some(), "--report-path-prefix"),
4093 (cli.fail_on_regression, "--fail-on-regression"),
4094 (cli.fail_on_stale_baseline, "--fail-on-stale-baseline"),
4095 (cli.fail_on_baseline_growth, "--fail-on-baseline-growth"),
4096 (cli.baseline_base.is_some(), "--baseline-base"),
4097 (cli.fail_on_parse_error, "--fail-on-parse-error"),
4098 (cli.tolerance != "0", "--tolerance"),
4099 (cli.regression_baseline.is_some(), "--regression-baseline"),
4100 (
4101 cli.save_regression_baseline.is_some(),
4102 "--save-regression-baseline",
4103 ),
4104 (!cli.only.is_empty(), "--only"),
4105 (!cli.skip.is_empty(), "--skip"),
4106 (dupes_flag.is_some(), dupes_flag.unwrap_or_default()),
4107 (cli.score, "--score"),
4108 (cli.trend, "--trend"),
4109 (cli.trend_from.is_some(), "--trend-from"),
4110 (cli.save_snapshot.is_some(), "--save-snapshot"),
4111 (cli.coverage.is_some(), "--coverage"),
4112 (cli.coverage_root.is_some(), "--coverage-root"),
4113 (cli.dupes_baseline.is_some(), "--dupes-baseline"),
4114 (cli.health_baseline.is_some(), "--health-baseline"),
4115 (cli.include_entry_exports, "--include-entry-exports"),
4116 (cli.show_cascade, "--show-cascade"),
4117 (cli.type_aware, "--type-aware"),
4118 (cli.no_type_aware, "--no-type-aware"),
4119 (!cli.type_aware_project.is_empty(), "--type-aware-project"),
4120 (cli.type_aware_require.is_some(), "--type-aware-require"),
4121 ]
4122 .into_iter()
4123 .find_map(|(used, flag)| used.then_some(flag))
4124}
4125
4126fn run_schema_command_if_requested(
4127 cli: &Cli,
4128 json_style: json_style::JsonStyle,
4129) -> Option<ExitCode> {
4130 match cli.command {
4131 Some(Command::Schema) => Some(schema::run_schema(json_style)),
4132 Some(Command::ConfigSchema) => Some(init::run_config_schema(json_style)),
4133 Some(Command::PluginSchema) => Some(init::run_plugin_schema(json_style)),
4134 Some(Command::RulePackSchema) => Some(init::run_rule_pack_schema(json_style)),
4135 _ => None,
4136 }
4137}
4138
4139fn regression_save_targets(cli: &Cli) -> (Option<std::path::PathBuf>, bool) {
4140 let save_file = cli.save_regression_baseline.as_ref().and_then(|opt| {
4141 opt.as_ref()
4142 .filter(|path| !path.is_empty())
4143 .map(std::path::PathBuf::from)
4144 });
4145 let save_to_config = cli.save_regression_baseline.is_some() && save_file.is_none();
4146 (save_file, save_to_config)
4147}
4148
4149fn dispatch_bare_command(dispatch: &DispatchContext<'_>) -> ExitCode {
4150 let cli = dispatch.cli;
4151 let (run_check, run_dupes, run_health) = combined::resolve_analyses(&cli.only, &cli.skip);
4152 let production = match dispatch.production_modes(
4153 cli.production_dead_code,
4154 cli.production_health,
4155 cli.production_dupes,
4156 ) {
4157 Ok(production) => production,
4158 Err(code) => return code,
4159 };
4160 let coverage_inputs = if run_health {
4165 match resolve_health_coverage_inputs(
4166 dispatch,
4167 cli.coverage.as_deref(),
4168 cli.coverage_root.as_deref(),
4169 ) {
4170 Ok(inputs) => inputs,
4171 Err(code) => return code,
4172 }
4173 } else {
4174 ResolvedHealthCoverageInputs::default()
4175 };
4176 run_bare_combined(
4177 dispatch,
4178 production,
4179 &coverage_inputs,
4180 BareAnalyses {
4181 run_check,
4182 run_dupes,
4183 run_health,
4184 },
4185 )
4186}
4187
4188#[derive(Clone, Copy)]
4190struct BareAnalyses {
4191 run_check: bool,
4192 run_dupes: bool,
4193 run_health: bool,
4194}
4195
4196fn run_bare_combined(
4199 dispatch: &DispatchContext<'_>,
4200 production: ProductionModes,
4201 coverage_inputs: &ResolvedHealthCoverageInputs,
4202 analyses: BareAnalyses,
4203) -> ExitCode {
4204 let cli = dispatch.cli;
4205 let output = dispatch.output;
4206 if cli.fail_on_parse_error && !analyses.run_check && !analyses.run_health {
4207 return error::emit_error_with_style(
4208 "--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.",
4209 2,
4210 output,
4211 dispatch.json_style,
4212 );
4213 }
4214 let scope = match crate::scope_path::resolve_command_scope(
4215 dispatch.root,
4216 dispatch.output,
4217 cli.path.clone(),
4218 ) {
4219 Ok(scope) => scope.map(|resolved| resolved.absolute),
4220 Err(code) => return code,
4221 };
4222 let growth_targets = baseline_growth::targets(&[
4223 (
4224 cli.baseline.as_deref().filter(|_| analyses.run_check),
4225 fallow_engine::baseline::BaselineKind::DeadCode,
4226 ),
4227 (
4228 cli.dupes_baseline.as_deref().filter(|_| analyses.run_dupes),
4229 fallow_engine::baseline::BaselineKind::Dupes,
4230 ),
4231 (
4232 cli.health_baseline
4233 .as_deref()
4234 .filter(|_| analyses.run_health),
4235 fallow_engine::baseline::BaselineKind::Health,
4236 ),
4237 ]);
4238 baseline_growth::run_gated(
4239 &dispatch.growth_ctx(baseline_growth::GrowthOwner::Combined),
4240 &growth_targets,
4241 || run_combined_scoped(dispatch, production, coverage_inputs, analyses, scope),
4242 )
4243}
4244
4245fn run_combined_scoped(
4246 dispatch: &DispatchContext<'_>,
4247 production: ProductionModes,
4248 coverage_inputs: &ResolvedHealthCoverageInputs,
4249 analyses: BareAnalyses,
4250 scope: Option<PathBuf>,
4251) -> ExitCode {
4252 let cli = dispatch.cli;
4253 let (output, quiet, fail_on_issues) =
4254 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
4255 let scoped_run = scope.is_some();
4256 let architecture = combined::resolve_architecture_selection(&cli.only, &cli.skip);
4257 let narrowed_report = architecture != check::ArchitectureSelection::All;
4259 combined::run_combined(&combined::CombinedOptions {
4260 root: dispatch.root,
4261 config_path: &cli.config,
4262 output,
4263 json_style: dispatch.json_style,
4264 no_cache: cli.no_cache,
4265 threads: dispatch.threads,
4266 quiet,
4267 allow_remote_extends: cli.allow_remote_extends,
4268 fail_on_issues,
4269 sarif_file: cli.sarif_file.as_deref(),
4270 changed_since: cli.changed_since.as_deref(),
4271 no_package_baselines: cli.no_package_baselines,
4272 churn_file: cli.churn_file.as_deref(),
4273 baseline: cli.baseline.as_deref(),
4274 save_baseline: cli.save_baseline.as_deref(),
4275 dupes_baseline: cli.dupes_baseline.as_deref(),
4276 health_baseline: cli.health_baseline.as_deref(),
4277 health_baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
4278 health_baseline_mode_explicit: cli.baseline_mode.is_some(),
4279 fail_on_stale_baseline: cli.fail_on_stale_baseline,
4280 production: cli.production,
4281 production_dead_code: Some(production.dead_code),
4282 production_health: Some(production.health),
4283 production_dupes: Some(production.dupes),
4284 workspace: cli.workspace.as_deref(),
4285 changed_workspaces: cli.changed_workspaces.as_deref(),
4286 group_by: cli.group_by,
4287 type_aware: cli.type_aware_override(),
4288 type_aware_projects: &cli.type_aware_project,
4289 type_aware_require: cli.type_aware_require.map(Into::into),
4290 explain: cli.explain,
4291 explain_skipped: cli.explain_skipped,
4292 performance: cli.performance,
4293 summary: cli.summary,
4294 run_check: analyses.run_check,
4295 run_dupes: analyses.run_dupes,
4296 run_health: analyses.run_health,
4297 architecture,
4298 dupes: cli.dupes_overrides(),
4299 score: cli.score || cli.trend || cli.trend_from.is_some(),
4300 trend: cli.trend || cli.trend_from.is_some(),
4301 trend_from: cli.trend_from.as_deref(),
4302 save_snapshot: cli.save_snapshot.as_ref(),
4303 coverage: coverage_inputs.coverage.as_deref(),
4304 coverage_root: coverage_inputs.coverage_root.as_deref(),
4305 include_entry_exports: cli.include_entry_exports,
4306 show_cascade: cli.show_cascade,
4307 fail_on_parse_error: cli.fail_on_parse_error,
4308 scope,
4309 regression_opts: dispatch.regression_opts(
4310 cli.changed_since.is_some()
4311 || cli.workspace.is_some()
4312 || cli.changed_workspaces.is_some()
4313 || scoped_run
4314 || narrowed_report,
4315 ),
4316 })
4317}
4318
4319#[allow(
4320 clippy::too_many_lines,
4321 reason = "the command router is intentionally an exhaustive top-level dispatch table"
4322)]
4323fn dispatch_subcommand(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4324 let cli = dispatch.cli;
4325 let root = dispatch.root;
4326 let output = dispatch.output;
4327 let quiet = dispatch.quiet;
4328 match command {
4329 check @ Command::Check { .. } => dispatch_check_command(check, dispatch),
4330 architecture @ Command::Architecture { .. } => {
4331 dispatch_architecture_command(architecture, dispatch)
4332 }
4333 Command::Watch { no_clear } => dispatch_watch(dispatch, no_clear),
4334 Command::TypeAware { subcommand } => dispatch_type_aware_command(dispatch, subcommand),
4335 Command::Doctor => unreachable!("doctor bypasses the normal dispatch epilogue"),
4336 Command::SimilarCode {
4337 subcommand,
4338 threshold,
4339 min_lines,
4340 top,
4341 file,
4342 path,
4343 } => {
4344 let scope = match crate::scope_path::resolve_command_scope(
4345 dispatch.root,
4346 dispatch.output,
4347 path,
4348 ) {
4349 Ok(scope) => scope,
4350 Err(code) => return code,
4351 };
4352 similar_code_cli::run(similar_code_cli::SimilarCodeCliInput {
4353 root,
4354 config_path: cli.config.as_deref(),
4355 allow_remote_extends: cli.allow_remote_extends,
4356 no_cache: cli.no_cache,
4357 threads: dispatch.threads,
4358 changed_since: cli.changed_since.as_deref(),
4359 diff_file: cli.diff_file.as_deref(),
4360 workspace: cli.workspace.as_deref(),
4361 changed_workspaces: cli.changed_workspaces.as_deref(),
4362 explain: cli.explain,
4363 quiet,
4364 output,
4365 json_style: dispatch.json_style,
4366 threshold,
4367 min_lines,
4368 top,
4369 files: file,
4370 scope,
4371 subcommand,
4372 })
4373 }
4374 Command::Inspect {
4375 file,
4376 symbol,
4377 symbol_chain,
4378 churn,
4379 } => dispatch_inspect_command(dispatch, file, symbol, symbol_chain, churn),
4380 Command::Trace {
4381 symbol,
4382 dependency,
4383 specifier,
4384 sites,
4385 limit,
4386 cursor,
4387 path,
4388 eager_only,
4389 callers,
4390 callees,
4391 depth,
4392 } => {
4393 if let Some(package_name) = dependency {
4394 return dispatch_trace_dependency(
4395 dispatch,
4396 trace_dependency::TraceDependencyTarget {
4397 package_name,
4398 specifiers: specifier,
4399 sites,
4400 limit,
4401 cursor,
4402 callers,
4403 depth,
4404 },
4405 );
4406 }
4407 dispatch_trace_command(
4408 dispatch,
4409 symbol,
4410 &path,
4411 eager_only,
4412 TraceChainFlags {
4413 callers,
4414 callees,
4415 depth,
4416 },
4417 )
4418 }
4419 Command::TraceError { trace_file } => {
4420 trace_error::run_trace_error(&trace_error::TraceErrorOptions {
4421 root: dispatch.root,
4422 config_path: &dispatch.cli.config,
4423 output: dispatch.output,
4424 json_style: dispatch.json_style,
4425 no_cache: dispatch.cli.no_cache,
4426 threads: dispatch.threads,
4427 quiet: dispatch.quiet,
4428 allow_remote_extends: dispatch.cli.allow_remote_extends,
4429 trace_file: trace_file.as_deref(),
4430 })
4431 }
4432 fix @ Command::Fix { .. } => dispatch_fix_command(&fix, dispatch),
4433 init @ Command::Init { .. } => dispatch_init_command(init, root, quiet),
4434 Command::Hooks { subcommand } => {
4435 run_hooks_command(root, subcommand, output, dispatch.json_style)
4436 }
4437 Command::Agent { subcommand } => dispatch_agent_command(dispatch, subcommand),
4438 Command::Ci { subcommand } => {
4439 ci::run(map_ci_subcommand(subcommand), output, dispatch.json_style)
4440 }
4441 Command::ConfigSchema => init::run_config_schema(dispatch.json_style),
4442 Command::PluginSchema => init::run_plugin_schema(dispatch.json_style),
4443 Command::PluginCheck => plugin_check::run_plugin_check(root, output, dispatch.json_style),
4444 Command::RulePackSchema => init::run_rule_pack_schema(dispatch.json_style),
4445 Command::RulePack { subcommand } => dispatch_rule_pack_command(dispatch, subcommand),
4446 Command::Guard { files } => dispatch_guard_command(dispatch, &files),
4447 Command::CiTemplate { subcommand } => dispatch_ci_template_command(subcommand),
4448 Command::Config { path } => config::run_config_with_options(config::RunConfigInput {
4449 root,
4450 explicit_config: cli.config.as_deref(),
4451 path_only: path,
4452 output,
4453 quiet,
4454 json_style: dispatch.json_style,
4455 load_options: fallow_config::ConfigLoadOptions {
4456 allow_remote_extends: cli.allow_remote_extends,
4457 },
4458 }),
4459 Command::Recommend => onboarding::run_recommend(root, output, dispatch.json_style),
4460 list @ (Command::Workspaces | Command::List { .. }) => {
4461 dispatch_list_command(&list, dispatch)
4462 }
4463 dupes @ Command::Dupes { .. } => dispatch_dupes_command(dupes, dispatch),
4464 health @ Command::Health { .. } => dispatch_health_command(health, dispatch),
4465 Command::Flags {
4466 top,
4467 retirement,
4468 reasons,
4469 sort,
4470 flag_age,
4471 min_age,
4472 flag_state,
4473 max_flag_age,
4474 } => dispatch_flags_command(
4475 dispatch,
4476 top,
4477 retirement.then_some(flags::RetirementArgs {
4478 reasons,
4479 sort,
4480 flag_age,
4481 min_age,
4482 flag_state,
4483 max_flag_age,
4484 }),
4485 ),
4486 Command::Suppressions { file } => dispatch_suppressions_command(dispatch, &file),
4487 Command::Explain { issue_type } => {
4488 explain::run_explain(&issue_type.join(" "), output, dispatch.json_style)
4489 }
4490 audit @ Command::Audit { .. } => dispatch_audit_command(audit, dispatch),
4491 Command::AuditCache { subcommand } => dispatch_audit_cache_command(dispatch, &subcommand),
4492 Command::Baselines { subcommand } => dispatch_baselines_command(dispatch, &subcommand),
4493 Command::DecisionSurface { max_decisions } => {
4494 dispatch_decision_surface(dispatch, max_decisions)
4495 }
4496 Command::Impact {
4497 subcommand,
4498 all,
4499 sort,
4500 limit,
4501 } => dispatch_impact(
4502 root,
4503 quiet,
4504 output,
4505 dispatch.json_style,
4506 subcommand,
4507 ImpactCrossRepoOpts { all, sort, limit },
4508 ),
4509 security @ Command::Security { .. } => dispatch_security_command(security, dispatch),
4510 Command::Viz {
4511 output: viz_output,
4512 no_open,
4513 viz_format,
4514 } => dispatch_viz(dispatch, viz_output.as_deref(), no_open, viz_format),
4515 Command::Report { from } => {
4516 cli_report::run_report(&from, output, root, cli.config.as_deref(), cli.quiet)
4517 }
4518 Command::Schema => unreachable!("handled above"),
4519 migrate @ Command::Migrate { .. } => dispatch_migrate_command(migrate, root),
4520 Command::License { subcommand } => {
4521 dispatch_license_command(subcommand, output, dispatch.json_style)
4522 }
4523 Command::Telemetry { .. } => unreachable!("handled before root validation"),
4524 Command::Coverage { subcommand } => dispatch_coverage_command(dispatch, &subcommand),
4525 setup_hooks @ Command::SetupHooks { .. } => {
4526 dispatch_setup_hooks_command(&setup_hooks, dispatch)
4527 }
4528 }
4529}
4530
4531fn dispatch_type_aware_command(
4532 dispatch: &DispatchContext<'_>,
4533 subcommand: TypeAwareCli,
4534) -> ExitCode {
4535 match subcommand {
4536 TypeAwareCli::Status => {
4537 let status = fallow_api::type_aware_status(dispatch.root);
4538 match dispatch.output {
4539 fallow_config::OutputFormat::Json => {
4540 let output = type_aware_status_output(dispatch.root, status);
4541 match fallow_output::serialize_type_aware_status_json_output(output) {
4542 Ok(value) => match dispatch.json_style.serialize(&value) {
4543 Ok(json) => {
4544 crate::report::sink::outln!("{json}");
4545 ExitCode::SUCCESS
4546 }
4547 Err(error) => emit_error(
4548 &format!("failed to serialize type-aware status: {error}"),
4549 2,
4550 dispatch.output,
4551 ),
4552 },
4553 Err(error) => emit_error(
4554 &format!("failed to build type-aware status: {error}"),
4555 2,
4556 dispatch.output,
4557 ),
4558 }
4559 }
4560 fallow_config::OutputFormat::Human => {
4561 if status.available {
4562 crate::report::sink::outln!(
4563 "{}",
4564 report::human_status_line(
4565 report::HumanStatus::Ok,
4566 format_args!(
4567 "Type-aware companion: available ({}, protocol {}, TypeScript {})",
4568 status.package_version.as_deref().unwrap_or("unknown"),
4569 status.protocol_version,
4570 status.backend_version.as_deref().unwrap_or("unknown"),
4571 )
4572 )
4573 );
4574 } else {
4575 crate::report::sink::outln!(
4576 "{}",
4577 report::human_status_line(
4578 report::HumanStatus::Inactive,
4579 "Type-aware companion: unavailable"
4580 )
4581 );
4582 if let Some(remediation) = status.remediation {
4583 crate::report::sink::outln!(
4584 "{}",
4585 report::human_status_line(
4586 report::HumanStatus::Warning,
4587 format_args!("Action: {remediation}")
4588 )
4589 );
4590 }
4591 }
4592 ExitCode::SUCCESS
4593 }
4594 _ => emit_error(
4595 "type-aware status supports human and json output",
4596 2,
4597 dispatch.output,
4598 ),
4599 }
4600 }
4601 }
4602}
4603
4604fn type_aware_status_output(
4605 root: &Path,
4606 status: fallow_api::TypeAwareStatus,
4607) -> fallow_output::TypeAwareStatusOutput {
4608 let companion_path = status.companion_path.as_deref().map(|path| {
4609 if let Ok(relative) = path.strip_prefix(root)
4610 && !relative.as_os_str().is_empty()
4611 {
4612 relative.to_string_lossy().replace('\\', "/")
4613 } else {
4614 path.file_name()
4615 .unwrap_or(path.as_os_str())
4616 .to_string_lossy()
4617 .into_owned()
4618 }
4619 });
4620 let remediation = status.remediation.map(|message| {
4621 let without_root = message.replace(root.to_string_lossy().as_ref(), ".");
4622 status.companion_path.as_deref().map_or_else(
4623 || without_root.clone(),
4624 |path| {
4625 without_root.replace(
4626 path.to_string_lossy().as_ref(),
4627 companion_path.as_deref().unwrap_or("fallow-type-aware"),
4628 )
4629 },
4630 )
4631 });
4632 fallow_output::TypeAwareStatusOutput {
4633 schema_version: fallow_types::envelope::SchemaVersion(
4634 fallow_output::TYPE_AWARE_STATUS_SCHEMA_VERSION,
4635 ),
4636 version: fallow_types::envelope::ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
4637 available: status.available,
4638 discovery_source: status.discovery_source.map(str::to_string),
4639 companion_path,
4640 package_version: status.package_version,
4641 protocol_version: status.protocol_version,
4642 backend_family: status.backend_family,
4643 backend_version: status.backend_version,
4644 remediation,
4645 }
4646}
4647
4648fn dispatch_check_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4650 let filters = check_issue_filters(&command);
4651 let Command::Check {
4652 include_dupes,
4653 trace,
4654 trace_file,
4655 trace_dependency,
4656 impact_closure,
4657 symbol_impact,
4658 top,
4659 file,
4660 finding_id,
4661 path,
4662 ..
4663 } = command
4664 else {
4665 unreachable!("check dispatcher only handles check commands");
4666 };
4667
4668 let finding_ids = match fallow_engine::dead_code::FindingIdFilter::parse(&finding_id) {
4669 Ok(filter) => filter,
4670 Err(message) => return emit_error(&format!("--finding-id: {message}"), 2, dispatch.output),
4671 };
4672
4673 let scope = match crate::scope_path::resolve_command_scope(dispatch.root, dispatch.output, path)
4674 {
4675 Ok(scope) => scope.map(|resolved| resolved.absolute),
4676 Err(code) => return code,
4677 };
4678
4679 dispatch_check(
4680 dispatch,
4681 &CheckDispatchArgs {
4682 filters,
4683 trace_opts: TraceOptions {
4684 trace_export: trace,
4685 trace_file,
4686 trace_dependency,
4687 impact_closure,
4688 symbol_impact,
4689 performance: dispatch.cli.performance,
4690 },
4691 include_dupes,
4692 type_aware: dispatch.cli.type_aware_override(),
4693 type_aware_project: dispatch.cli.type_aware_project.clone(),
4694 type_aware_require: dispatch.cli.type_aware_require,
4695 top,
4696 file,
4697 scope,
4698 finding_ids,
4699 surface: check::CheckSurface::DeadCode,
4700 },
4701 )
4702}
4703
4704fn dispatch_architecture_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4708 let Command::Architecture {
4709 cycles,
4710 boundaries,
4711 policy,
4712 file,
4713 finding_id,
4714 path,
4715 } = command
4716 else {
4717 unreachable!("architecture dispatcher only handles architecture commands");
4718 };
4719
4720 let finding_ids = match fallow_engine::dead_code::FindingIdFilter::parse(&finding_id) {
4721 Ok(filter) => filter,
4722 Err(message) => return emit_error(&format!("--finding-id: {message}"), 2, dispatch.output),
4723 };
4724
4725 let scope = match crate::scope_path::resolve_command_scope(dispatch.root, dispatch.output, path)
4726 {
4727 Ok(scope) => scope.map(|resolved| resolved.absolute),
4728 Err(code) => return code,
4729 };
4730
4731 dispatch_check(
4732 dispatch,
4733 &CheckDispatchArgs {
4734 filters: architecture_issue_filters(cycles, boundaries, policy),
4735 trace_opts: TraceOptions {
4736 trace_export: None,
4737 trace_file: None,
4738 trace_dependency: None,
4739 impact_closure: None,
4740 symbol_impact: None,
4741 performance: dispatch.cli.performance,
4742 },
4743 include_dupes: false,
4744 type_aware: dispatch.cli.type_aware_override(),
4745 type_aware_project: dispatch.cli.type_aware_project.clone(),
4746 type_aware_require: dispatch.cli.type_aware_require,
4747 top: None,
4748 file,
4749 scope,
4750 finding_ids,
4751 surface: check::CheckSurface::Architecture,
4752 },
4753 )
4754}
4755
4756fn architecture_issue_filters(cycles: bool, boundaries: bool, policy: bool) -> IssueFilters {
4759 let all = !(cycles || boundaries || policy);
4760 let mut filters = IssueFilters::default();
4761 for (flag, active) in [
4762 ("--circular-deps", cycles || all),
4763 ("--re-export-cycles", cycles || all),
4764 ("--package-cycles", cycles || all),
4765 ("--boundary-violations", boundaries || all),
4766 ("--policy-violations", policy || all),
4767 ] {
4768 enable_check_filter(&mut filters, flag, active);
4769 }
4770 filters
4771}
4772
4773fn check_issue_filters(command: &Command) -> IssueFilters {
4778 check_issue_filters_framework(command, &check_issue_filters_core(command))
4779}
4780
4781fn check_issue_filters_core(command: &Command) -> IssueFilters {
4784 let Command::Check {
4785 unused_files,
4786 unused_exports,
4787 unused_deps,
4788 unused_types,
4789 private_type_leaks,
4790 deprecated_exports_in_use,
4791 unused_enum_members,
4792 unused_class_members,
4793 unresolved_imports,
4794 unlisted_deps,
4795 duplicate_exports,
4796 circular_deps,
4797 re_export_cycles,
4798 package_cycles,
4799 boundary_violations,
4800 policy_violations,
4801 stale_suppressions,
4802 ..
4803 } = command
4804 else {
4805 unreachable!("check filter builder only handles check commands");
4806 };
4807
4808 let mut filters = IssueFilters::default();
4809 for (flag, active) in [
4810 ("--unused-files", *unused_files),
4811 ("--unused-exports", *unused_exports),
4812 ("--unused-deps", *unused_deps),
4813 ("--unused-types", *unused_types),
4814 ("--private-type-leaks", *private_type_leaks),
4815 ("--deprecated-exports-in-use", *deprecated_exports_in_use),
4816 ("--unused-enum-members", *unused_enum_members),
4817 ("--unused-class-members", *unused_class_members),
4818 ("--unresolved-imports", *unresolved_imports),
4819 ("--unlisted-deps", *unlisted_deps),
4820 ("--duplicate-exports", *duplicate_exports),
4821 ("--circular-deps", *circular_deps),
4822 ("--re-export-cycles", *re_export_cycles),
4823 ("--package-cycles", *package_cycles),
4824 ("--boundary-violations", *boundary_violations),
4825 ("--policy-violations", *policy_violations),
4826 ("--stale-suppressions", *stale_suppressions),
4827 ] {
4828 enable_check_filter(&mut filters, flag, active);
4829 }
4830 filters
4831}
4832
4833fn check_issue_filters_framework(command: &Command, base: &IssueFilters) -> IssueFilters {
4836 let Command::Check {
4837 unused_store_members,
4838 unprovided_injects,
4839 unrendered_components,
4840 unused_component_props,
4841 absent_component_props,
4842 unused_component_emits,
4843 unused_component_inputs,
4844 unused_component_outputs,
4845 unused_svelte_events,
4846 unused_server_actions,
4847 unused_load_data_keys,
4848 unused_catalog_entries,
4849 empty_catalog_groups,
4850 unresolved_catalog_references,
4851 unused_dependency_overrides,
4852 misconfigured_dependency_overrides,
4853 ..
4854 } = command
4855 else {
4856 unreachable!("check filter builder only handles check commands");
4857 };
4858
4859 let mut filters = base.clone();
4860 for (flag, active) in [
4861 ("--unused-store-members", *unused_store_members),
4862 ("--unprovided-injects", *unprovided_injects),
4863 ("--unrendered-components", *unrendered_components),
4864 ("--unused-component-props", *unused_component_props),
4865 ("--absent-component-props", *absent_component_props),
4866 ("--unused-component-emits", *unused_component_emits),
4867 ("--unused-component-inputs", *unused_component_inputs),
4868 ("--unused-component-outputs", *unused_component_outputs),
4869 ("--unused-svelte-events", *unused_svelte_events),
4870 ("--unused-server-actions", *unused_server_actions),
4871 ("--unused-load-data-keys", *unused_load_data_keys),
4872 ("--unused-catalog-entries", *unused_catalog_entries),
4873 ("--empty-catalog-groups", *empty_catalog_groups),
4874 (
4875 "--unresolved-catalog-references",
4876 *unresolved_catalog_references,
4877 ),
4878 (
4879 "--unused-dependency-overrides",
4880 *unused_dependency_overrides,
4881 ),
4882 (
4883 "--misconfigured-dependency-overrides",
4884 *misconfigured_dependency_overrides,
4885 ),
4886 ] {
4887 enable_check_filter(&mut filters, flag, active);
4888 }
4889 filters
4890}
4891
4892fn enable_check_filter(filters: &mut IssueFilters, flag: &str, active: bool) {
4893 if active {
4894 assert!(
4895 filters.enable_cli_filter_flag(flag),
4896 "check command uses unregistered dead-code filter flag {flag}"
4897 );
4898 }
4899}
4900
4901fn dispatch_inspect_command(
4902 dispatch: &DispatchContext<'_>,
4903 file: Option<String>,
4904 symbol: Option<String>,
4905 symbol_chain: bool,
4906 churn: bool,
4907) -> ExitCode {
4908 let target = match (file, symbol) {
4909 (Some(file), None) => inspect::InspectTarget::File { file },
4910 (None, Some(symbol)) => match selector::parse_file_symbol_selector(&symbol) {
4911 Some((file, export_name)) => inspect::InspectTarget::Symbol {
4912 file: file.to_string(),
4913 export_name: export_name.to_string(),
4914 },
4915 None => {
4916 return emit_error(
4917 "--symbol must be formatted as FILE:EXPORT",
4918 2,
4919 dispatch.output,
4920 );
4921 }
4922 },
4923 _ => {
4924 return emit_error(
4925 "inspect requires exactly one of --file or --symbol",
4926 2,
4927 dispatch.output,
4928 );
4929 }
4930 };
4931
4932 let churn_config = if churn {
4933 match load_config_for_analysis(
4934 dispatch.root,
4935 &dispatch.cli.config,
4936 ConfigLoadOptions {
4937 output: dispatch.output,
4938 no_cache: dispatch.cli.no_cache,
4939 threads: dispatch.threads,
4940 production_override: None,
4941 quiet: dispatch.quiet,
4942 allow_remote_extends: dispatch.cli.allow_remote_extends,
4943 },
4944 fallow_config::ProductionAnalysis::Health,
4945 ) {
4946 Ok(config) => Some(config),
4947 Err(code) => return code,
4948 }
4949 } else {
4950 None
4951 };
4952
4953 inspect::run_inspect(&inspect::InspectOptions {
4954 root: dispatch.root,
4955 config_path: dispatch.cli.config.as_ref(),
4956 output: dispatch.output,
4957 json_style: dispatch.json_style,
4958 no_cache: dispatch.cli.no_cache,
4959 no_production: dispatch.cli.no_production,
4960 max_file_size: dispatch.cli.max_file_size,
4961 threads: dispatch.threads,
4962 quiet: dispatch.quiet,
4963 production: dispatch.cli.production,
4964 workspace: dispatch.cli.workspace.as_ref(),
4965 target,
4966 churn_cache_dir: churn_config
4967 .as_ref()
4968 .map(|config| config.cache_dir.as_path()),
4969 symbol_chain,
4970 type_aware: dispatch.cli.type_aware_override(),
4971 type_aware_projects: &dispatch.cli.type_aware_project,
4972 type_aware_require: dispatch.cli.type_aware_require.map(Into::into),
4973 })
4974}
4975
4976#[derive(Clone, Copy)]
4978struct TraceChainFlags {
4979 callers: bool,
4980 callees: bool,
4981 depth: Option<u32>,
4982}
4983
4984fn dispatch_trace_dependency(
4985 dispatch: &DispatchContext<'_>,
4986 target: trace_dependency::TraceDependencyTarget,
4987) -> ExitCode {
4988 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
4989 Ok(production) => production,
4990 Err(code) => return code,
4991 };
4992 trace_dependency::run_trace_dependency(&trace_dependency::TraceDependencyOptions {
4993 root: dispatch.root,
4994 config_path: &dispatch.cli.config,
4995 output: dispatch.output,
4996 json_style: dispatch.json_style,
4997 no_cache: dispatch.cli.no_cache,
4998 threads: dispatch.threads,
4999 quiet: dispatch.quiet,
5000 allow_remote_extends: dispatch.cli.allow_remote_extends,
5001 production,
5002 workspace: dispatch.cli.workspace.as_deref(),
5003 changed_workspaces: dispatch.cli.changed_workspaces.as_deref(),
5004 target,
5005 })
5006}
5007
5008fn dispatch_trace_command(
5009 dispatch: &DispatchContext<'_>,
5010 symbol: Option<String>,
5011 path: &[String],
5012 eager_only: bool,
5013 chain: TraceChainFlags,
5014) -> ExitCode {
5015 let TraceChainFlags {
5016 callers,
5017 callees,
5018 depth,
5019 } = chain;
5020 if let [from, to] = path {
5021 return trace_path::run_trace_path(&trace_path::TracePathOptions {
5022 root: dispatch.root,
5023 config_path: &dispatch.cli.config,
5024 output: dispatch.output,
5025 json_style: dispatch.json_style,
5026 no_cache: dispatch.cli.no_cache,
5027 threads: dispatch.threads,
5028 quiet: dispatch.quiet,
5029 allow_remote_extends: dispatch.cli.allow_remote_extends,
5030 from,
5031 to,
5032 eager_only,
5033 });
5034 }
5035 let Some(symbol) = symbol else {
5036 return emit_error(
5037 "trace requires a FILE:SYMBOL target or --path <FROM> <TO>",
5038 2,
5039 dispatch.output,
5040 );
5041 };
5042 trace_chain::run_trace(&trace_chain::TraceChainOptions {
5043 root: dispatch.root,
5044 config_path: &dispatch.cli.config,
5045 output: dispatch.output,
5046 json_style: dispatch.json_style,
5047 no_cache: dispatch.cli.no_cache,
5048 threads: dispatch.threads,
5049 quiet: dispatch.quiet,
5050 allow_remote_extends: dispatch.cli.allow_remote_extends,
5051 target: symbol,
5052 callers,
5053 callees,
5054 depth: depth.unwrap_or(fallow_types::trace_chain::DEFAULT_TRACE_DEPTH),
5055 })
5056}
5057
5058fn dispatch_security_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
5059 let Command::Security {
5060 subcommand,
5061 runtime_coverage,
5062 min_invocations_hot,
5063 file,
5064 gate,
5065 surface,
5066 path,
5067 } = command
5068 else {
5069 unreachable!("security dispatcher only handles security commands");
5070 };
5071
5072 let scope = match crate::scope_path::resolve_command_scope(dispatch.root, dispatch.output, path)
5073 {
5074 Ok(scope) => scope.map(|resolved| resolved.absolute),
5075 Err(code) => return code,
5076 };
5077
5078 let gate = gate.map(security::SecurityGateArg::into_mode);
5079 let cli = dispatch.cli;
5080 let (output, _quiet, fail_on_issues) =
5081 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
5082 let derived_flags = SecurityDerivedFlagState {
5083 output,
5084 json_style: dispatch.json_style,
5085 ci: cli.ci,
5086 fail_on_issues,
5087 sarif_file: cli.sarif_file.as_deref(),
5088 summary: cli.summary,
5089 explain: cli.explain,
5090 runtime_coverage: runtime_coverage.as_deref(),
5091 min_invocations_hot,
5092 file: file.as_slice(),
5093 gate,
5094 surface,
5095 };
5096 if let Some(code) = try_run_security_survivors(subcommand.as_ref(), &derived_flags) {
5097 return code;
5098 }
5099
5100 let scoped_files = scoped_security_files(&file, subcommand.as_ref());
5101 run_security_blind_spots_or_default(
5102 dispatch,
5103 &SecurityRunInputs {
5104 scoped_files: &scoped_files,
5105 subcommand: &subcommand,
5106 runtime_coverage: runtime_coverage.as_deref(),
5107 min_invocations_hot,
5108 gate,
5109 surface,
5110 scope,
5111 },
5112 &derived_flags,
5113 )
5114}
5115
5116struct SecurityRunInputs<'a> {
5119 scoped_files: &'a [PathBuf],
5120 subcommand: &'a Option<SecuritySubcommand>,
5121 runtime_coverage: Option<&'a Path>,
5122 min_invocations_hot: u64,
5123 gate: Option<security::SecurityGateMode>,
5124 surface: bool,
5125 scope: Option<PathBuf>,
5126}
5127
5128fn run_security_blind_spots_or_default(
5130 dispatch: &DispatchContext<'_>,
5131 inputs: &SecurityRunInputs<'_>,
5132 derived_flags: &SecurityDerivedFlagState<'_>,
5133) -> ExitCode {
5134 let cli = dispatch.cli;
5135 let (output, quiet, fail_on_issues) =
5136 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
5137 let opts = security::SecurityOptions {
5138 root: dispatch.root,
5139 config_path: &cli.config,
5140 output,
5141 json_style: dispatch.json_style,
5142 no_cache: cli.no_cache,
5143 threads: dispatch.threads,
5144 quiet,
5145 allow_remote_extends: cli.allow_remote_extends,
5146 fail_on_issues,
5147 sarif_file: cli.sarif_file.as_deref(),
5148 summary: cli.summary,
5149 changed_since: cli.changed_since.as_deref(),
5150 use_shared_diff_index: true,
5151 workspace: cli.workspace.as_deref(),
5152 changed_workspaces: cli.changed_workspaces.as_deref(),
5153 file: inputs.scoped_files,
5154 surface: inputs.surface,
5155 scope: inputs.scope.clone(),
5156 gate: inputs.gate,
5157 runtime_coverage: inputs.runtime_coverage,
5158 min_invocations_hot: inputs.min_invocations_hot,
5159 explain: cli.explain,
5160 };
5161 if matches!(
5162 inputs.subcommand,
5163 Some(SecuritySubcommand::BlindSpots { .. })
5164 ) {
5165 if let Some(code) = validate_security_blind_spots_flags(derived_flags) {
5166 return code;
5167 }
5168 security::run_blind_spots(&opts)
5169 } else {
5170 security::run(&opts)
5171 }
5172}
5173
5174fn try_run_security_survivors(
5177 subcommand: Option<&SecuritySubcommand>,
5178 flags: &SecurityDerivedFlagState<'_>,
5179) -> Option<ExitCode> {
5180 let Some(SecuritySubcommand::Survivors {
5181 candidates,
5182 verdicts,
5183 require_verdict_for_each_candidate,
5184 }) = subcommand
5185 else {
5186 return None;
5187 };
5188 if let Some(code) = validate_security_survivors_flags(flags) {
5189 return Some(code);
5190 }
5191 Some(security::run_survivors(
5192 &security::SecuritySurvivorsOptions {
5193 output: flags.output,
5194 json_style: flags.json_style,
5195 candidates,
5196 verdicts,
5197 require_verdict_for_each_candidate: *require_verdict_for_each_candidate,
5198 },
5199 ))
5200}
5201
5202fn scoped_security_files(
5204 file: &[PathBuf],
5205 subcommand: Option<&SecuritySubcommand>,
5206) -> Vec<PathBuf> {
5207 let mut scoped_files = file.to_vec();
5208 if let Some(SecuritySubcommand::BlindSpots {
5209 file: blind_spot_files,
5210 }) = subcommand
5211 {
5212 scoped_files.extend(blind_spot_files.iter().cloned());
5213 }
5214 scoped_files
5215}
5216
5217struct SecurityDerivedFlagState<'a> {
5218 output: fallow_config::OutputFormat,
5219 json_style: json_style::JsonStyle,
5220 ci: bool,
5221 fail_on_issues: bool,
5222 sarif_file: Option<&'a Path>,
5223 summary: bool,
5224 explain: bool,
5225 runtime_coverage: Option<&'a Path>,
5226 min_invocations_hot: u64,
5227 file: &'a [PathBuf],
5228 gate: Option<security::SecurityGateMode>,
5229 surface: bool,
5230}
5231
5232fn validate_security_survivors_flags(flags: &SecurityDerivedFlagState<'_>) -> Option<ExitCode> {
5233 let flag = if flags.ci {
5234 Some("--ci")
5235 } else if flags.fail_on_issues {
5236 Some("--fail-on-issues")
5237 } else if flags.sarif_file.is_some() {
5238 Some("--sarif-file")
5239 } else if flags.summary {
5240 Some("--summary")
5241 } else if flags.explain {
5242 Some("--explain")
5243 } else if flags.runtime_coverage.is_some() {
5244 Some("--runtime-coverage")
5245 } else if flags.min_invocations_hot != DEFAULT_MIN_INVOCATIONS_HOT {
5246 Some("--min-invocations-hot")
5247 } else if !flags.file.is_empty() {
5248 Some("--file")
5249 } else if flags.gate.is_some() {
5250 Some("--gate")
5251 } else if flags.surface {
5252 Some("--surface")
5253 } else {
5254 None
5255 }?;
5256 Some(emit_error(
5257 &format!("{flag} is not valid with `fallow security survivors`."),
5258 2,
5259 flags.output,
5260 ))
5261}
5262
5263fn validate_security_blind_spots_flags(flags: &SecurityDerivedFlagState<'_>) -> Option<ExitCode> {
5264 let flag = if flags.ci {
5265 Some("--ci")
5266 } else if flags.fail_on_issues {
5267 Some("--fail-on-issues")
5268 } else if flags.sarif_file.is_some() {
5269 Some("--sarif-file")
5270 } else if flags.summary {
5271 Some("--summary")
5272 } else if flags.explain {
5273 Some("--explain")
5274 } else if flags.runtime_coverage.is_some() {
5275 Some("--runtime-coverage")
5276 } else if flags.min_invocations_hot != DEFAULT_MIN_INVOCATIONS_HOT {
5277 Some("--min-invocations-hot")
5278 } else if flags.gate.is_some() {
5279 Some("--gate")
5280 } else if flags.surface {
5281 Some("--surface")
5282 } else {
5283 None
5284 }?;
5285 Some(emit_error(
5286 &format!("{flag} is not valid with `fallow security blind-spots`."),
5287 2,
5288 flags.output,
5289 ))
5290}
5291
5292fn dispatch_dupes_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
5293 let Command::Dupes {
5294 mode,
5295 near,
5296 min_tokens,
5297 min_lines,
5298 min_occurrences,
5299 threshold,
5300 skip_local,
5301 ignore_symlinks,
5302 no_ignore_symlinks,
5303 cross_language,
5304 ignore_imports,
5305 no_ignore_imports,
5306 top,
5307 no_fragments,
5308 trace,
5309 path,
5310 } = command
5311 else {
5312 unreachable!("dupes dispatcher only handles dupes commands");
5313 };
5314
5315 let scope = match crate::scope_path::resolve_command_scope(dispatch.root, dispatch.output, path)
5316 {
5317 Ok(scope) => scope.map(|resolved| resolved.absolute),
5318 Err(code) => return code,
5319 };
5320
5321 dispatch_dupes(
5322 dispatch,
5323 &DupesDispatchArgs {
5324 overrides: DupesOverrides {
5325 mode,
5326 near,
5327 threshold,
5328 min_tokens,
5329 min_lines,
5330 min_occurrences,
5331 skip_local,
5332 ignore_symlinks: resolve_negatable_flag(ignore_symlinks, no_ignore_symlinks),
5333 cross_language,
5334 ignore_imports: resolve_negatable_flag(ignore_imports, no_ignore_imports),
5335 },
5336 top,
5337 no_fragments,
5338 trace,
5339 scope,
5340 },
5341 )
5342}
5343
5344fn dispatch_agent_command(dispatch: &DispatchContext<'_>, subcommand: AgentCli) -> ExitCode {
5345 run_agent_command(
5346 dispatch.root,
5347 dispatch.cli.root.is_some(),
5348 subcommand,
5349 dispatch.output,
5350 dispatch.json_style,
5351 )
5352}
5353
5354fn dispatch_init_command(command: Command, root: &Path, quiet: bool) -> ExitCode {
5355 let Command::Init {
5356 toml,
5357 agents,
5358 hooks,
5359 branch,
5360 decline,
5361 } = command
5362 else {
5363 unreachable!("init dispatcher only handles init commands");
5364 };
5365
5366 init::run_init(&init::InitOptions {
5367 root,
5368 use_toml: toml,
5369 agents,
5370 hooks,
5371 branch: branch.as_deref(),
5372 decline,
5373 quiet,
5374 })
5375}
5376
5377fn dispatch_fix_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
5378 let Command::Fix {
5379 dry_run,
5380 yes,
5381 no_create_config,
5382 path,
5383 } = command
5384 else {
5385 unreachable!("fix dispatcher only handles fix commands");
5386 };
5387
5388 let scope = match crate::scope_path::resolve_command_scope(
5389 dispatch.root,
5390 dispatch.output,
5391 path.clone(),
5392 ) {
5393 Ok(scope) => scope.map(|resolved| resolved.absolute),
5394 Err(code) => return code,
5395 };
5396
5397 dispatch_fix(
5398 dispatch,
5399 &FixDispatchArgs {
5400 dry_run: *dry_run,
5401 yes: *yes,
5402 no_create_config: *no_create_config,
5403 scope,
5404 },
5405 )
5406}
5407
5408fn dispatch_list_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
5409 match command {
5410 Command::Workspaces => dispatch_list(dispatch, &ListDispatchArgs::workspaces()),
5411 Command::List {
5412 entry_points,
5413 files,
5414 plugins,
5415 boundaries,
5416 workspaces,
5417 entry_weight,
5418 path,
5419 } => {
5420 let scope = match crate::scope_path::resolve_command_scope(
5421 dispatch.root,
5422 dispatch.output,
5423 path.clone(),
5424 ) {
5425 Ok(scope) => scope.map(|resolved| resolved.absolute),
5426 Err(code) => return code,
5427 };
5428 dispatch_list(
5429 dispatch,
5430 &ListDispatchArgs {
5431 entry_points: *entry_points,
5432 files: *files,
5433 plugins: *plugins,
5434 boundaries: *boundaries,
5435 workspaces: *workspaces,
5436 entry_weight: *entry_weight,
5437 scope,
5438 },
5439 )
5440 }
5441 _ => unreachable!("list dispatcher only handles list commands"),
5442 }
5443}
5444
5445fn dispatch_migrate_command(command: Command, root: &Path) -> ExitCode {
5446 let Command::Migrate {
5447 toml,
5448 jsonc,
5449 dry_run,
5450 from,
5451 } = command
5452 else {
5453 unreachable!("migrate dispatcher only handles migrate commands");
5454 };
5455
5456 migrate::run_migrate(root, toml, jsonc, dry_run, from.as_deref())
5457}
5458
5459fn dispatch_license_command(
5460 subcommand: LicenseCli,
5461 output: fallow_config::OutputFormat,
5462 json_style: json_style::JsonStyle,
5463) -> ExitCode {
5464 license::run(&map_license_subcommand(subcommand), output, json_style)
5465}
5466
5467fn dispatch_ci_template_command(subcommand: CiTemplateCli) -> ExitCode {
5468 match subcommand {
5469 CiTemplateCli::Gitlab { vendor, force } => {
5470 ci_template::run_gitlab_template(&ci_template::GitlabTemplateOptions {
5471 vendor_dir: vendor,
5472 force,
5473 })
5474 }
5475 }
5476}
5477
5478fn dispatch_coverage_command(dispatch: &DispatchContext<'_>, subcommand: &CoverageCli) -> ExitCode {
5479 let cli = dispatch.cli;
5480 coverage::run(
5481 map_coverage_subcommand(subcommand, cli.explain, cli.changed_since.as_deref()),
5482 &coverage::RunContext {
5483 root: dispatch.root,
5484 config_path: &cli.config,
5485 output: dispatch.output,
5486 json_style: dispatch.json_style,
5487 quiet: dispatch.quiet,
5488 no_cache: cli.no_cache,
5489 threads: dispatch.threads,
5490 explain: cli.explain,
5491 allow_remote_extends: cli.allow_remote_extends,
5492 },
5493 )
5494}
5495
5496fn dispatch_health_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
5497 let Command::Health {
5498 max_cyclomatic,
5499 max_cognitive,
5500 max_crap,
5501 top,
5502 sort,
5503 complexity,
5504 complexity_breakdown,
5505 file_scores,
5506 coverage_gaps,
5507 hotspots,
5508 ownership,
5509 ownership_emails,
5510 targets,
5511 type_coupling,
5512 css,
5513 effort,
5514 score,
5515 min_score,
5516 min_severity,
5517 report_only,
5518 since,
5519 min_commits,
5520 save_snapshot,
5521 trend,
5522 trend_from,
5523 coverage,
5524 coverage_root,
5525 runtime_coverage,
5526 min_invocations_hot,
5527 min_observation_volume,
5528 low_traffic_threshold,
5529 path,
5530 } = command
5531 else {
5532 unreachable!("health dispatcher only handles health commands");
5533 };
5534
5535 let scope = match crate::scope_path::resolve_command_scope(dispatch.root, dispatch.output, path)
5536 {
5537 Ok(scope) => scope.map(|resolved| resolved.absolute),
5538 Err(code) => return code,
5539 };
5540
5541 let ownership = ownership || ownership_emails.is_some();
5542 let hotspots = hotspots || ownership;
5543 let args = HealthDispatchArgs {
5544 max_cyclomatic,
5545 max_cognitive,
5546 max_crap,
5547 top,
5548 sort,
5549 complexity,
5550 complexity_breakdown,
5551 file_scores,
5552 coverage_gaps,
5553 hotspots,
5554 ownership,
5555 ownership_emails: ownership_emails.map(EmailModeArg::to_config),
5556 targets,
5557 type_coupling,
5558 css,
5559 effort,
5560 score,
5561 min_score,
5562 min_severity: min_severity.map(HealthSeverityCli::to_health_severity),
5563 report_only,
5564 since: since.as_deref(),
5565 min_commits,
5566 save_snapshot: save_snapshot.as_ref(),
5567 trend: trend || trend_from.is_some(),
5568 trend_from: trend_from.as_deref(),
5569 fail_on_stale_baseline: dispatch.cli.fail_on_stale_baseline,
5570 fail_on_parse_error: dispatch.cli.fail_on_parse_error,
5571 coverage: coverage.as_deref(),
5572 coverage_root: coverage_root.as_deref(),
5573 runtime_coverage: runtime_coverage.as_deref(),
5574 min_invocations_hot,
5575 min_observation_volume,
5576 low_traffic_threshold,
5577 scope,
5578 };
5579 dispatch_health(dispatch, &args)
5580}
5581
5582fn dispatch_setup_hooks_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
5583 let Command::SetupHooks {
5584 agent,
5585 dry_run,
5586 force,
5587 user,
5588 gitignore_claude,
5589 uninstall,
5590 } = command
5591 else {
5592 unreachable!("setup-hooks dispatcher only handles setup-hooks commands");
5593 };
5594
5595 eprintln!(
5596 "warning: `fallow setup-hooks` is deprecated and will be removed in the next major; use `fallow agent install` or `fallow hooks install --target agent`."
5597 );
5598 setup_hooks::run_setup_hooks(&setup_hooks::SetupHooksOptions {
5599 root: dispatch.root,
5600 agent: *agent,
5601 dry_run: *dry_run,
5602 force: *force,
5603 user: *user,
5604 home: None,
5605 gitignore_claude: *gitignore_claude,
5606 uninstall: *uninstall,
5607 })
5608}
5609
5610fn dispatch_audit_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
5611 let Command::Audit {
5612 production_dead_code,
5613 production_health,
5614 production_dupes,
5615 dead_code_baseline,
5616 health_baseline,
5617 dupes_baseline,
5618 max_crap,
5619 coverage,
5620 coverage_root,
5621 no_css,
5622 css_deep,
5623 no_css_deep,
5624 gate,
5625 runtime_coverage,
5626 min_invocations_hot,
5627 gate_marker,
5628 brief,
5629 max_decisions,
5630 walkthrough_guide,
5631 walkthrough_file,
5632 walkthrough,
5633 mark_viewed,
5634 show_cleared,
5635 show_deprioritized,
5636 path,
5637 } = command
5638 else {
5639 unreachable!("audit dispatcher only handles audit commands");
5640 };
5641
5642 let brief = brief || walkthrough_guide || walkthrough || walkthrough_file.is_some();
5645
5646 let scope = match crate::scope_path::resolve_command_scope(dispatch.root, dispatch.output, path)
5647 {
5648 Ok(scope) => scope.map(|resolved| resolved.absolute),
5649 Err(code) => return code,
5650 };
5651
5652 dispatch_audit(
5653 dispatch,
5654 &AuditDispatchArgs {
5655 production_dead_code,
5656 production_health,
5657 production_dupes,
5658 dead_code_baseline,
5659 health_baseline,
5660 dupes_baseline,
5661 max_crap,
5662 coverage,
5663 coverage_root,
5664 no_css,
5665 css_deep,
5666 no_css_deep,
5667 gate,
5668 runtime_coverage,
5669 min_invocations_hot,
5670 gate_marker,
5671 brief,
5672 max_decisions,
5673 walkthrough_guide,
5674 walkthrough_file,
5675 walkthrough,
5676 mark_viewed,
5677 show_cleared,
5678 show_deprioritized,
5679 scope,
5680 },
5681 )
5682}
5683
5684fn dispatch_audit_cache_command(
5685 dispatch: &DispatchContext<'_>,
5686 subcommand: &AuditCacheCli,
5687) -> ExitCode {
5688 match subcommand {
5689 AuditCacheCli::Remove { dry_run, yes } => {
5690 if !*dry_run && !*yes && !std::io::stdin().is_terminal() {
5691 return emit_error(
5692 "audit-cache remove requires --yes (or --force) in non-interactive environments. Use --dry-run to preview removal first, then pass --yes to confirm.",
5693 2,
5694 dispatch.output,
5695 );
5696 }
5697 match base_worktree::remove_reusable_audit_caches(dispatch.root, *dry_run) {
5698 Ok(report) => {
5699 let action = if *dry_run { "would remove" } else { "removed" };
5700 if matches!(dispatch.output, fallow_config::OutputFormat::Json) {
5701 let value = serde_json::json!({
5702 "kind": "audit-cache-remove",
5703 "schema_version": 1,
5704 "command": "audit-cache remove",
5705 "root": dispatch.root,
5706 "dry_run": report.dry_run,
5707 "found": report.found,
5708 "would_remove": report.found.saturating_sub(report.skipped),
5709 "removed": report.removed,
5710 "skipped": report.skipped,
5711 "complete": report.skipped == 0,
5712 });
5713 let output_code = report::emit_report_json(
5714 &value,
5715 "audit cache removal",
5716 dispatch.json_style,
5717 );
5718 if output_code != ExitCode::SUCCESS {
5719 return output_code;
5720 }
5721 } else if !dispatch.quiet {
5722 println!(
5723 "audit cache: {action} {}, skipped {} for {}",
5724 if *dry_run {
5725 report.found.saturating_sub(report.skipped)
5726 } else {
5727 report.removed
5728 },
5729 report.skipped,
5730 dispatch.root.display(),
5731 );
5732 }
5733 if report.skipped == 0 {
5734 ExitCode::SUCCESS
5735 } else {
5736 ExitCode::from(2)
5737 }
5738 }
5739 Err(error) => emit_error(
5740 &format!(
5741 "failed to remove audit caches for {}: {error}",
5742 dispatch.root.display()
5743 ),
5744 2,
5745 dispatch.output,
5746 ),
5747 }
5748 }
5749 AuditCacheCli::Prune {
5750 dry_run,
5751 max_age_days,
5752 } => audit_cache_prune::run_audit_cache_prune(&audit_cache_prune::AuditCachePruneOptions {
5753 root: dispatch.root,
5754 config_path: dispatch.cli.config.as_ref(),
5755 allow_remote_extends: dispatch.cli.allow_remote_extends,
5756 dry_run: *dry_run,
5757 max_age_days: *max_age_days,
5758 output: dispatch.output,
5759 json_style: dispatch.json_style,
5760 quiet: dispatch.quiet,
5761 }),
5762 }
5763}
5764
5765fn dispatch_baselines_command(
5766 dispatch: &DispatchContext<'_>,
5767 subcommand: &BaselinesCli,
5768) -> ExitCode {
5769 let BaselinesCli::Prune {
5770 check,
5771 dead_code_baseline,
5772 health_baseline,
5773 dupes_baseline,
5774 coverage,
5775 coverage_root,
5776 } = subcommand;
5777 let cli = dispatch.cli;
5778 let root = dispatch.root;
5779 let output = dispatch.output;
5780 if let Some(flag) = baselines_prune_narrowing_flag(cli) {
5781 return emit_error(
5782 &format!(
5783 "`fallow baselines prune` always analyzes the whole project, so it does not accept `{flag}`. A narrowed run would remove valid entries."
5784 ),
5785 2,
5786 output,
5787 );
5788 }
5789 if let Some(flag) = cli_global_baseline_flag(cli) {
5790 return emit_error(
5791 &format!(
5792 "`fallow baselines prune` does not accept `{flag}`. Name the files with --dead-code-baseline, --health-baseline and --dupes-baseline, or with `audit.*Baseline` in the config."
5793 ),
5794 2,
5795 output,
5796 );
5797 }
5798 let config = match load_config(
5799 root,
5800 &cli.config,
5801 LoadConfigArgs {
5802 output,
5803 no_cache: cli.no_cache,
5804 threads: dispatch.threads,
5805 production: false,
5806 quiet: dispatch.quiet,
5807 allow_remote_extends: cli.allow_remote_extends,
5808 },
5809 ) {
5810 Ok(config) => config,
5811 Err(code) => return code,
5812 };
5813 let targets = baselines::BaselineTargets {
5814 dead_code: resolve_audit_baseline_path(
5815 root,
5816 dead_code_baseline.as_deref(),
5817 config.audit.dead_code_baseline.as_deref(),
5818 ),
5819 health: resolve_audit_baseline_path(
5820 root,
5821 health_baseline.as_deref(),
5822 config.audit.health_baseline.as_deref(),
5823 ),
5824 dupes: resolve_audit_baseline_path(
5825 root,
5826 dupes_baseline.as_deref(),
5827 config.audit.dupes_baseline.as_deref(),
5828 ),
5829 };
5830 if targets.dead_code.is_none() && targets.health.is_none() && targets.dupes.is_none() {
5831 return emit_error(
5832 "no baseline to prune. Set `audit.deadCodeBaseline`, `audit.healthBaseline` or `audit.dupesBaseline` in the config, or pass --dead-code-baseline, --health-baseline or --dupes-baseline",
5833 2,
5834 output,
5835 );
5836 }
5837 let production = match resolve_production_modes(cli, root, output, false, false, false) {
5838 Ok(production) => production,
5839 Err(code) => return code,
5840 };
5841 let coverage_inputs = match resolve_coverage_inputs(
5842 coverage.as_deref(),
5843 coverage_root.as_deref(),
5844 output,
5845 || Ok(config.health),
5846 ) {
5847 Ok(inputs) => inputs,
5848 Err(code) => return code,
5849 };
5850 baselines::run_baselines_prune(&baselines::BaselinesPruneOptions {
5851 root,
5852 config_path: &cli.config,
5853 output,
5854 json_style: dispatch.json_style,
5855 no_cache: cli.no_cache,
5856 threads: dispatch.threads,
5857 quiet: dispatch.quiet,
5858 allow_remote_extends: cli.allow_remote_extends,
5859 check: *check,
5860 targets,
5861 production,
5862 coverage: coverage_inputs.coverage.as_deref(),
5863 coverage_root: coverage_inputs.coverage_root.as_deref(),
5864 regression_opts: dispatch.regression_opts(true),
5865 })
5866}
5867
5868fn baselines_prune_narrowing_flag(cli: &Cli) -> Option<&'static str> {
5870 [
5871 (cli.changed_since.is_some(), "--changed-since"),
5872 (cli.workspace.is_some(), "--workspace"),
5873 (cli.changed_workspaces.is_some(), "--changed-workspaces"),
5874 (cli.diff_file.is_some(), "--diff-file"),
5875 (cli.diff_stdin, "--diff-stdin"),
5876 (
5877 std::env::var_os("FALLOW_DIFF_FILE").is_some_and(|value| !value.is_empty()),
5878 "FALLOW_DIFF_FILE",
5879 ),
5880 (cli.production, "--production"),
5881 ]
5882 .into_iter()
5883 .find_map(|(present, flag)| present.then_some(flag))
5884}
5885
5886fn dispatch_flags_command(
5887 dispatch: &DispatchContext<'_>,
5888 top: Option<usize>,
5889 retirement: Option<flags::RetirementArgs>,
5890) -> ExitCode {
5891 let cli = dispatch.cli;
5892 let root = dispatch.root;
5893 let output = dispatch.output;
5894 let quiet = dispatch.quiet;
5895 let threads = dispatch.threads;
5896 let production = match resolve_production_modes(cli, root, output, false, false, false) {
5897 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::DeadCode),
5898 Err(code) => return code,
5899 };
5900 flags::run_flags(&flags::FlagsOptions {
5901 root,
5902 config_path: &cli.config,
5903 output,
5904 json_style: dispatch.json_style,
5905 no_cache: cli.no_cache,
5906 threads,
5907 quiet,
5908 allow_remote_extends: cli.allow_remote_extends,
5909 production,
5910 workspace: cli.workspace.as_deref(),
5911 changed_workspaces: cli.changed_workspaces.as_deref(),
5912 changed_since: cli.changed_since.as_deref(),
5913 explain: cli.explain,
5914 top,
5915 retirement,
5916 regression: dispatch.regression_opts(false),
5917 regression_flag: first_regression_flag(cli),
5918 })
5919}
5920
5921fn first_regression_flag(cli: &Cli) -> Option<&'static str> {
5923 [
5924 (cli.fail_on_regression, "--fail-on-regression"),
5925 (cli.regression_baseline.is_some(), "--regression-baseline"),
5926 (
5927 cli.save_regression_baseline.is_some(),
5928 "--save-regression-baseline",
5929 ),
5930 (cli.tolerance != "0", "--tolerance"),
5931 ]
5932 .into_iter()
5933 .find_map(|(used, flag)| used.then_some(flag))
5934}
5935
5936fn dispatch_suppressions_command(
5937 dispatch: &DispatchContext<'_>,
5938 file: &[std::path::PathBuf],
5939) -> ExitCode {
5940 let cli = dispatch.cli;
5941 let root = dispatch.root;
5942 let output = dispatch.output;
5943 let production = match resolve_production_modes(cli, root, output, false, false, false) {
5944 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::DeadCode),
5945 Err(code) => return code,
5946 };
5947 suppressions::run_suppressions(&suppressions::SuppressionsOptions {
5948 root,
5949 config_path: &cli.config,
5950 output,
5951 json_style: dispatch.json_style,
5952 no_cache: cli.no_cache,
5953 threads: dispatch.threads,
5954 quiet: dispatch.quiet,
5955 allow_remote_extends: cli.allow_remote_extends,
5956 production,
5957 workspace: cli.workspace.as_deref(),
5958 changed_workspaces: cli.changed_workspaces.as_deref(),
5959 changed_since: cli.changed_since.as_deref(),
5960 file,
5961 })
5962}
5963
5964fn dispatch_guard_command(dispatch: &DispatchContext<'_>, files: &[String]) -> ExitCode {
5965 guard::run_guard(&guard::GuardOptions {
5966 root: dispatch.root,
5967 config_path: &dispatch.cli.config,
5968 output: dispatch.output,
5969 json_style: dispatch.json_style,
5970 quiet: dispatch.quiet,
5971 allow_remote_extends: dispatch.cli.allow_remote_extends,
5972 files,
5973 })
5974}
5975
5976fn dispatch_rule_pack_command(dispatch: &DispatchContext<'_>, subcommand: RulePackCli) -> ExitCode {
5977 let ctx = rule_pack::RulePackContext {
5978 root: dispatch.root,
5979 config_path: &dispatch.cli.config,
5980 output: dispatch.output,
5981 json_style: dispatch.json_style,
5982 quiet: dispatch.quiet,
5983 no_cache: dispatch.cli.no_cache,
5984 threads: Some(dispatch.threads),
5985 allow_remote_extends: dispatch.cli.allow_remote_extends,
5986 };
5987 rule_pack::run(&map_rule_pack_subcommand(subcommand), &ctx)
5988}
5989
5990fn map_rule_pack_subcommand(subcommand: RulePackCli) -> rule_pack::RulePackSubcommand {
5991 match subcommand {
5992 RulePackCli::Init {
5993 name,
5994 template,
5995 dir,
5996 no_config,
5997 } => rule_pack::RulePackSubcommand::Init(rule_pack::InitArgs {
5998 name,
5999 template,
6000 dir,
6001 no_config,
6002 }),
6003 RulePackCli::List => rule_pack::RulePackSubcommand::List,
6004 RulePackCli::Test { pack } => {
6005 rule_pack::RulePackSubcommand::Test(rule_pack::TestArgs { pack })
6006 }
6007 RulePackCli::Schema => rule_pack::RulePackSubcommand::Schema,
6008 }
6009}
6010
6011fn map_license_subcommand(sub: LicenseCli) -> license::LicenseSubcommand {
6012 match sub {
6013 LicenseCli::Activate {
6014 jwt,
6015 from_file,
6016 stdin,
6017 trial,
6018 email,
6019 } => license::LicenseSubcommand::Activate(license::ActivateArgs {
6020 raw_jwt: jwt,
6021 from_file,
6022 from_stdin: stdin,
6023 trial,
6024 email,
6025 }),
6026 LicenseCli::Status => license::LicenseSubcommand::Status,
6027 LicenseCli::Refresh { api_key } => {
6028 license::LicenseSubcommand::Refresh(license::RefreshArgs { api_key })
6029 }
6030 LicenseCli::Deactivate => license::LicenseSubcommand::Deactivate,
6031 }
6032}
6033
6034fn map_telemetry_subcommand(sub: TelemetryCli) -> telemetry::TelemetryCommand {
6035 match sub {
6036 TelemetryCli::Status => telemetry::TelemetryCommand::Status,
6037 TelemetryCli::Enable => telemetry::TelemetryCommand::Enable,
6038 TelemetryCli::Disable => telemetry::TelemetryCommand::Disable,
6039 TelemetryCli::Inspect { example } => telemetry::TelemetryCommand::Inspect { example },
6040 }
6041}
6042
6043fn map_ci_subcommand(sub: CiCli) -> ci::CiCommand {
6044 match sub {
6045 command @ CiCli::PlanPrComment { .. } => map_ci_plan_pr_comment(command),
6046 command @ CiCli::PostPrComment { .. } => map_ci_post_pr_comment(command),
6047 command @ CiCli::PostReview { .. } => map_ci_post_review(command),
6048 command @ CiCli::PostCheckRun { .. } => map_ci_post_check_run(command),
6049 command @ CiCli::ReconcileReview { .. } => map_ci_reconcile_review(command),
6050 }
6051}
6052
6053fn map_ci_plan_pr_comment(command: CiCli) -> ci::CiCommand {
6054 let CiCli::PlanPrComment {
6055 body,
6056 marker_id,
6057 clean,
6058 existing_comment_id,
6059 existing_body,
6060 } = command
6061 else {
6062 unreachable!("ci plan-pr-comment mapper called with different variant");
6063 };
6064
6065 ci::CiCommand::PlanPrComment {
6066 body,
6067 marker_id,
6068 clean,
6069 existing_comment_id,
6070 existing_body,
6071 }
6072}
6073
6074fn map_ci_post_pr_comment(command: CiCli) -> ci::CiCommand {
6075 let CiCli::PostPrComment {
6076 provider,
6077 pr,
6078 mr,
6079 body,
6080 envelope,
6081 marker_id,
6082 clean,
6083 repo,
6084 project_id,
6085 api_url,
6086 dry_run,
6087 } = command
6088 else {
6089 unreachable!("ci post-pr-comment mapper called with different variant");
6090 };
6091
6092 ci::CiCommand::PostPrComment {
6093 provider: map_ci_provider(provider),
6094 target: pr.or(mr),
6095 body,
6096 envelope,
6097 marker_id,
6098 clean,
6099 repo,
6100 project_id,
6101 api_url,
6102 dry_run,
6103 }
6104}
6105
6106fn map_ci_post_review(command: CiCli) -> ci::CiCommand {
6107 let CiCli::PostReview {
6108 provider,
6109 pr,
6110 mr,
6111 envelope,
6112 repo,
6113 project_id,
6114 api_url,
6115 dry_run,
6116 } = command
6117 else {
6118 unreachable!("ci post-review mapper called with different variant");
6119 };
6120
6121 ci::CiCommand::PostReview {
6122 provider: map_ci_provider(provider),
6123 target: pr.or(mr),
6124 envelope,
6125 repo,
6126 project_id,
6127 api_url,
6128 dry_run,
6129 }
6130}
6131
6132fn map_ci_post_check_run(command: CiCli) -> ci::CiCommand {
6133 let CiCli::PostCheckRun {
6134 provider,
6135 decision,
6136 repo,
6137 head_sha,
6138 api_url,
6139 split_gates,
6140 dry_run,
6141 } = command
6142 else {
6143 unreachable!("ci post-check-run mapper called with different variant");
6144 };
6145
6146 ci::CiCommand::PostCheckRun {
6147 provider: map_ci_provider(provider),
6148 decision,
6149 repo,
6150 head_sha,
6151 api_url,
6152 split_gates,
6153 dry_run,
6154 }
6155}
6156
6157fn map_ci_reconcile_review(command: CiCli) -> ci::CiCommand {
6158 let CiCli::ReconcileReview {
6159 provider,
6160 pr,
6161 mr,
6162 envelope,
6163 repo,
6164 project_id,
6165 api_url,
6166 dry_run,
6167 } = command
6168 else {
6169 unreachable!("ci reconcile-review mapper called with different variant");
6170 };
6171
6172 ci::CiCommand::ReconcileReview {
6173 provider: map_ci_provider(provider),
6174 target: pr.or(mr),
6175 envelope,
6176 repo,
6177 project_id,
6178 api_url,
6179 dry_run,
6180 }
6181}
6182
6183fn map_ci_provider(provider: CiProviderArg) -> ci::CiProvider {
6184 match provider {
6185 CiProviderArg::Github => ci::CiProvider::Github,
6186 CiProviderArg::Gitlab => ci::CiProvider::Gitlab,
6187 }
6188}
6189
6190fn map_coverage_subcommand(
6191 sub: &CoverageCli,
6192 explain: bool,
6193 base: Option<&str>,
6194) -> coverage::CoverageSubcommand {
6195 match sub {
6196 CoverageCli::Setup {
6197 yes,
6198 non_interactive,
6199 json,
6200 } => map_coverage_setup(*yes, *non_interactive, *json, explain),
6201 CoverageCli::Analyze { .. } => map_coverage_analyze(sub),
6202 CoverageCli::UploadInventory { .. } => map_coverage_upload_inventory(sub),
6203 CoverageCli::UploadSourceMaps { .. } => map_coverage_upload_source_maps(sub),
6204 CoverageCli::UploadStaticFindings { .. } => map_coverage_upload_static_findings(sub),
6205 CoverageCli::ReviewPacket {
6206 api_key,
6207 api_endpoint,
6208 repo,
6209 project_id,
6210 coverage_period,
6211 commit_sha,
6212 files,
6213 functions,
6214 } => coverage::CoverageSubcommand::ReviewPacket(coverage::ReviewPacketArgs {
6215 api_key: api_key.clone(),
6216 api_endpoint: api_endpoint.clone(),
6217 repo: repo.clone(),
6218 project_id: project_id.clone(),
6219 coverage_period: *coverage_period,
6220 commit_sha: commit_sha.clone(),
6221 files: files.clone(),
6222 functions: functions.clone(),
6223 base: base.map(str::to_owned),
6224 }),
6225 CoverageCli::DeploymentChanges {
6226 api_key,
6227 api_endpoint,
6228 repo,
6229 sha,
6230 change,
6231 limit,
6232 cursor,
6233 } => coverage::CoverageSubcommand::DeploymentChanges(coverage::DeploymentChangesArgs {
6234 api_key: api_key.clone(),
6235 api_endpoint: api_endpoint.clone(),
6236 repo: repo.clone(),
6237 sha: sha.clone(),
6238 base: base.map(str::to_owned),
6239 change: change.clone(),
6240 limit: *limit,
6241 cursor: cursor.clone(),
6242 }),
6243 }
6244}
6245
6246fn map_coverage_setup(
6247 yes: bool,
6248 non_interactive: bool,
6249 json: bool,
6250 explain: bool,
6251) -> coverage::CoverageSubcommand {
6252 coverage::CoverageSubcommand::Setup(coverage::SetupArgs {
6253 yes,
6254 non_interactive: non_interactive || json,
6255 json,
6256 explain,
6257 })
6258}
6259
6260fn map_coverage_analyze(sub: &CoverageCli) -> coverage::CoverageSubcommand {
6261 let CoverageCli::Analyze {
6262 runtime_coverage,
6263 cloud,
6264 api_key,
6265 api_endpoint,
6266 repo,
6267 project_id,
6268 coverage_period,
6269 environment,
6270 commit_sha,
6271 production,
6272 min_invocations_hot,
6273 min_observation_volume,
6274 low_traffic_threshold,
6275 top,
6276 blast_radius,
6277 importance,
6278 debug_unmatched,
6279 } = sub
6280 else {
6281 unreachable!("coverage analyze mapper called with non-analyze variant");
6282 };
6283 coverage::CoverageSubcommand::Analyze(coverage::AnalyzeArgs {
6284 runtime_coverage: runtime_coverage.clone(),
6285 cloud: *cloud,
6286 api_key: api_key.clone(),
6287 api_endpoint: api_endpoint.clone(),
6288 repo: repo.clone(),
6289 project_id: project_id.clone(),
6290 coverage_period: *coverage_period,
6291 environment: environment.clone(),
6292 commit_sha: commit_sha.clone(),
6293 production: *production,
6294 min_invocations_hot: *min_invocations_hot,
6295 min_observation_volume: *min_observation_volume,
6296 low_traffic_threshold: *low_traffic_threshold,
6297 top: *top,
6298 blast_radius: *blast_radius,
6299 importance: *importance,
6300 debug_unmatched: *debug_unmatched,
6301 })
6302}
6303
6304fn map_coverage_upload_inventory(sub: &CoverageCli) -> coverage::CoverageSubcommand {
6305 let CoverageCli::UploadInventory {
6306 api_key,
6307 api_endpoint,
6308 project_id,
6309 git_sha,
6310 allow_dirty,
6311 exclude_paths,
6312 path_prefix,
6313 dry_run,
6314 with_callers,
6315 ignore_upload_errors,
6316 } = sub
6317 else {
6318 unreachable!("coverage inventory mapper called with non-inventory variant");
6319 };
6320 coverage::CoverageSubcommand::UploadInventory(coverage::UploadInventoryArgs {
6321 api_key: api_key.clone(),
6322 api_endpoint: api_endpoint.clone(),
6323 project_id: project_id.clone(),
6324 git_sha: git_sha.clone(),
6325 allow_dirty: *allow_dirty,
6326 exclude_paths: exclude_paths.clone(),
6327 path_prefix: path_prefix.clone(),
6328 dry_run: *dry_run,
6329 with_callers: *with_callers,
6330 ignore_upload_errors: *ignore_upload_errors,
6331 })
6332}
6333
6334fn map_coverage_upload_source_maps(sub: &CoverageCli) -> coverage::CoverageSubcommand {
6335 let CoverageCli::UploadSourceMaps {
6336 dir,
6337 include,
6338 exclude,
6339 repo,
6340 git_sha,
6341 endpoint,
6342 strip_path,
6343 dry_run,
6344 concurrency,
6345 fail_fast,
6346 } = sub
6347 else {
6348 unreachable!("coverage source-map mapper called with non-source-map variant");
6349 };
6350 coverage::CoverageSubcommand::UploadSourceMaps(coverage::UploadSourceMapsArgs {
6351 dir: dir.clone(),
6352 include: include.clone(),
6353 exclude: exclude.clone(),
6354 repo: repo.clone(),
6355 git_sha: git_sha.clone(),
6356 endpoint: endpoint.clone(),
6357 strip_path: *strip_path,
6358 dry_run: *dry_run,
6359 concurrency: *concurrency,
6360 fail_fast: *fail_fast,
6361 })
6362}
6363
6364fn map_coverage_upload_static_findings(sub: &CoverageCli) -> coverage::CoverageSubcommand {
6365 let CoverageCli::UploadStaticFindings {
6366 api_key,
6367 api_endpoint,
6368 project_id,
6369 git_sha,
6370 allow_dirty,
6371 dry_run,
6372 ignore_upload_errors,
6373 } = sub
6374 else {
6375 unreachable!("coverage static-findings mapper called with non-static variant");
6376 };
6377 coverage::CoverageSubcommand::UploadStaticFindings(coverage::UploadStaticFindingsArgs {
6378 api_key: api_key.clone(),
6379 api_endpoint: api_endpoint.clone(),
6380 project_id: project_id.clone(),
6381 git_sha: git_sha.clone(),
6382 allow_dirty: *allow_dirty,
6383 dry_run: *dry_run,
6384 ignore_upload_errors: *ignore_upload_errors,
6385 })
6386}
6387
6388struct CheckDispatchArgs {
6389 filters: IssueFilters,
6390 trace_opts: TraceOptions,
6391 include_dupes: bool,
6392 type_aware: Option<bool>,
6393 type_aware_project: Vec<std::path::PathBuf>,
6394 type_aware_require: Option<TypeAwareRequireArg>,
6395 top: Option<usize>,
6396 file: Vec<std::path::PathBuf>,
6397 scope: Option<std::path::PathBuf>,
6398 finding_ids: Option<fallow_engine::dead_code::FindingIdFilter>,
6399 surface: check::CheckSurface,
6400}
6401
6402#[derive(Clone)]
6403struct ListDispatchArgs {
6404 entry_points: bool,
6405 files: bool,
6406 plugins: bool,
6407 boundaries: bool,
6408 workspaces: bool,
6409 entry_weight: bool,
6410 scope: Option<std::path::PathBuf>,
6411}
6412
6413impl ListDispatchArgs {
6414 fn workspaces() -> Self {
6415 Self {
6416 entry_points: false,
6417 files: false,
6418 plugins: false,
6419 boundaries: false,
6420 workspaces: true,
6421 entry_weight: false,
6422 scope: None,
6423 }
6424 }
6425}
6426
6427fn dispatch_viz(
6428 dispatch: &DispatchContext<'_>,
6429 output_path: Option<&std::path::Path>,
6430 no_open: bool,
6431 format: viz::VizFormat,
6432) -> ExitCode {
6433 let cli = dispatch.cli;
6434 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
6435 Ok(production) => production,
6436 Err(code) => return code,
6437 };
6438 viz::run_viz(&viz::VizOptions {
6439 root: dispatch.root,
6440 config_path: &cli.config,
6441 no_cache: cli.no_cache,
6442 threads: dispatch.threads,
6443 quiet: dispatch.quiet,
6444 production,
6445 allow_remote_extends: cli.allow_remote_extends,
6446 output_path,
6447 no_open,
6448 format,
6449 })
6450}
6451
6452fn dispatch_watch(dispatch: &DispatchContext<'_>, no_clear: bool) -> ExitCode {
6453 let cli = dispatch.cli;
6454 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
6455 Ok(production) => production,
6456 Err(code) => return code,
6457 };
6458 watch::run_watch(&watch::WatchOptions {
6459 root: dispatch.root,
6460 config_path: &cli.config,
6461 output: dispatch.output,
6462 json_style: dispatch.json_style,
6463 no_cache: cli.no_cache,
6464 threads: dispatch.threads,
6465 quiet: dispatch.quiet,
6466 allow_remote_extends: cli.allow_remote_extends,
6467 production,
6468 clear_screen: !no_clear,
6469 explain: cli.explain,
6470 include_entry_exports: cli.include_entry_exports,
6471 show_cascade: cli.show_cascade,
6472 type_aware: cli.type_aware_override(),
6473 type_aware_projects: &cli.type_aware_project,
6474 type_aware_require: cli.type_aware_require.map(Into::into),
6475 })
6476}
6477
6478struct FixDispatchArgs {
6479 dry_run: bool,
6480 yes: bool,
6481 no_create_config: bool,
6482 scope: Option<std::path::PathBuf>,
6483}
6484
6485fn dispatch_fix(dispatch: &DispatchContext<'_>, args: &FixDispatchArgs) -> ExitCode {
6486 let cli = dispatch.cli;
6487 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
6488 Ok(production) => production,
6489 Err(code) => return code,
6490 };
6491 fix::run_fix(&fix::FixOptions {
6492 root: dispatch.root,
6493 config_path: &cli.config,
6494 output: dispatch.output,
6495 json_style: dispatch.json_style,
6496 no_cache: cli.no_cache,
6497 threads: dispatch.threads,
6498 quiet: dispatch.quiet,
6499 emit_output: true,
6500 allow_remote_extends: cli.allow_remote_extends,
6501 dry_run: args.dry_run,
6502 yes: args.yes,
6503 production,
6504 no_create_config: args.no_create_config,
6505 type_aware: cli.type_aware_override(),
6506 type_aware_projects: &cli.type_aware_project,
6507 type_aware_require: cli.type_aware_require.map(Into::into),
6508 scope: args.scope.clone(),
6509 show_cascade: cli.show_cascade,
6510 })
6511}
6512
6513fn dispatch_list(dispatch: &DispatchContext<'_>, args: &ListDispatchArgs) -> ExitCode {
6514 let cli = dispatch.cli;
6515 let (save_regression_file, save_to_config) = regression_save_targets(cli);
6516 let entry_weight_gate = if args.entry_weight {
6519 let tolerance = match regression::Tolerance::parse(&cli.tolerance) {
6520 Ok(tolerance) => tolerance,
6521 Err(message) => {
6522 return emit_error(
6523 &format!("invalid --tolerance: {message}"),
6524 2,
6525 dispatch.output,
6526 );
6527 }
6528 };
6529 Some(regression::EntryWeightGate {
6530 fail_on_regression: cli.fail_on_regression,
6531 tolerance,
6532 baseline_file: cli.regression_baseline.as_deref(),
6533 save_file: save_regression_file.as_deref(),
6534 save_to_config,
6535 })
6536 } else {
6537 None
6538 };
6539 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
6540 Ok(production) => production,
6541 Err(code) => return code,
6542 };
6543 list::run_list(&ListOptions {
6544 root: dispatch.root,
6545 config_path: &cli.config,
6546 output: dispatch.output,
6547 json_style: dispatch.json_style,
6548 threads: dispatch.threads,
6549 no_cache: cli.no_cache,
6550 entry_points: args.entry_points,
6551 files: args.files,
6552 plugins: args.plugins,
6553 boundaries: args.boundaries,
6554 workspaces: args.workspaces,
6555 entry_weight: args.entry_weight,
6556 entry_weight_gate,
6557 production,
6558 allow_remote_extends: cli.allow_remote_extends,
6559 scope: args.scope.clone(),
6560 })
6561}
6562
6563fn dispatch_check(dispatch: &DispatchContext<'_>, args: &CheckDispatchArgs) -> ExitCode {
6564 let cli = dispatch.cli;
6565 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
6566 Ok(production) => production,
6567 Err(code) => return code,
6568 };
6569 if let Some(code) = validate_type_aware_check_options(dispatch, args) {
6570 return code;
6571 }
6572 let growth_targets = baseline_growth::targets(&[(
6573 cli.baseline.as_deref(),
6574 fallow_engine::baseline::BaselineKind::DeadCode,
6575 )]);
6576 baseline_growth::run_gated(
6577 &dispatch.growth_ctx(baseline_growth::GrowthOwner::DeadCode),
6578 &growth_targets,
6579 || dispatch_check_run(dispatch, args, production),
6580 )
6581}
6582
6583fn dispatch_check_run(
6584 dispatch: &DispatchContext<'_>,
6585 args: &CheckDispatchArgs,
6586 production: bool,
6587) -> ExitCode {
6588 let cli = dispatch.cli;
6589 let (output, quiet, fail_on_issues) =
6590 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
6591 check::run_check(&CheckOptions {
6592 root: dispatch.root,
6593 config_path: &cli.config,
6594 output,
6595 json_style: dispatch.json_style,
6596 no_cache: cli.no_cache,
6597 threads: dispatch.threads,
6598 quiet,
6599 allow_remote_extends: cli.allow_remote_extends,
6600 fail_on_issues,
6601 filters: &args.filters,
6602 changed_since: cli.changed_since.as_deref(),
6603 change_scope_owner: fallow_engine::change_scope::ChangeScopeOwner::Run,
6604 no_package_baselines: cli.no_package_baselines,
6605 diff_index: None,
6606 use_shared_diff_index: true,
6607 baseline: cli.baseline.as_deref(),
6608 baseline_flag: "--baseline",
6609 save_baseline: cli.save_baseline.as_deref(),
6610 fail_on_stale_baseline: cli.fail_on_stale_baseline,
6611 sarif_file: cli.sarif_file.as_deref(),
6612 production,
6613 production_override: Some(production),
6614 workspace: cli.workspace.as_deref(),
6615 changed_workspaces: cli.changed_workspaces.as_deref(),
6616 group_by: cli.group_by,
6617 include_dupes: args.include_dupes,
6618 type_aware: args.type_aware,
6619 type_aware_config_override: None,
6620 type_aware_projects: &args.type_aware_project,
6621 type_aware_require: args.type_aware_require.map(Into::into),
6622 trace_opts: &args.trace_opts,
6623 explain: cli.explain,
6624 top: args.top,
6625 file: &args.file,
6626 scope: args.scope.clone(),
6627 finding_ids: args.finding_ids.as_ref(),
6628 include_entry_exports: cli.include_entry_exports,
6629 show_cascade: cli.show_cascade,
6630 fail_on_parse_error: cli.fail_on_parse_error,
6631 summary: cli.summary,
6632 regression_opts: dispatch.regression_opts(
6633 cli.changed_since.is_some()
6634 || cli.workspace.is_some()
6635 || cli.changed_workspaces.is_some()
6636 || !args.file.is_empty()
6637 || args.scope.is_some(),
6638 ),
6639 retain_modules_for_health: false,
6640 defer_performance: true,
6641 analysis_snapshot: fallow_config::AnalysisSnapshot::Current,
6642 explain_skipped: cli.explain_skipped,
6643 surface: args.surface,
6644 })
6645}
6646
6647fn validate_type_aware_check_options(
6648 dispatch: &DispatchContext<'_>,
6649 args: &CheckDispatchArgs,
6650) -> Option<ExitCode> {
6651 let output = dispatch.output;
6652 if !args.type_aware_project.is_empty() && args.type_aware != Some(true) {
6653 return Some(emit_error(
6654 "--type-aware-project requires --type-aware",
6655 2,
6656 output,
6657 ));
6658 }
6659 if args.type_aware_require.is_some() && args.type_aware != Some(true) {
6660 return Some(emit_error(
6661 "--type-aware-require requires --type-aware",
6662 2,
6663 output,
6664 ));
6665 }
6666 if args.trace_opts.symbol_impact.is_some() && args.type_aware != Some(true) {
6667 return Some(emit_error(
6668 "--symbol-impact requires --type-aware",
6669 2,
6670 output,
6671 ));
6672 }
6673 let focused_output = args.trace_opts.trace_export.is_some()
6674 || args.trace_opts.trace_file.is_some()
6675 || args.trace_opts.trace_dependency.is_some()
6676 || args.trace_opts.impact_closure.is_some()
6677 || args.trace_opts.symbol_impact.is_some();
6678 if focused_output
6679 && !matches!(
6680 output,
6681 fallow_config::OutputFormat::Human | fallow_config::OutputFormat::Json
6682 )
6683 {
6684 return Some(emit_error(
6685 "focused trace and impact queries support human and JSON output",
6686 2,
6687 output,
6688 ));
6689 }
6690 if args.type_aware == Some(true)
6691 && !matches!(
6692 output,
6693 fallow_config::OutputFormat::Human
6694 | fallow_config::OutputFormat::Json
6695 | fallow_config::OutputFormat::Sarif
6696 | fallow_config::OutputFormat::Compact
6697 | fallow_config::OutputFormat::Markdown
6698 | fallow_config::OutputFormat::CodeClimate
6699 | fallow_config::OutputFormat::PrCommentGithub
6700 | fallow_config::OutputFormat::PrCommentGitlab
6701 | fallow_config::OutputFormat::ReviewGithub
6702 | fallow_config::OutputFormat::ReviewGitlab
6703 )
6704 {
6705 return Some(emit_error(
6706 "--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",
6707 2,
6708 output,
6709 ));
6710 }
6711 None
6712}
6713
6714const fn resolve_negatable_flag(opt_in: bool, opt_out: bool) -> Option<bool> {
6722 if opt_out {
6723 Some(false)
6724 } else if opt_in {
6725 Some(true)
6726 } else {
6727 None
6728 }
6729}
6730
6731struct DupesDispatchArgs {
6732 overrides: DupesOverrides,
6733 top: Option<usize>,
6734 no_fragments: bool,
6735 trace: Option<String>,
6736 scope: Option<std::path::PathBuf>,
6737}
6738
6739fn dispatch_dupes(dispatch: &DispatchContext<'_>, args: &DupesDispatchArgs) -> ExitCode {
6740 let cli = dispatch.cli;
6741 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::Dupes) {
6742 Ok(production) => production,
6743 Err(code) => return code,
6744 };
6745 let growth_targets = baseline_growth::targets(&[(
6746 cli.baseline.as_deref(),
6747 fallow_engine::baseline::BaselineKind::Dupes,
6748 )]);
6749 baseline_growth::run_gated(
6750 &dispatch.growth_ctx(baseline_growth::GrowthOwner::Dupes),
6751 &growth_targets,
6752 || dispatch_dupes_run(dispatch, args, production),
6753 )
6754}
6755
6756fn dispatch_dupes_run(
6757 dispatch: &DispatchContext<'_>,
6758 args: &DupesDispatchArgs,
6759 production: bool,
6760) -> ExitCode {
6761 let cli = dispatch.cli;
6762 let (output, quiet) = (dispatch.output, dispatch.quiet);
6763 dupes::run_dupes(&DupesOptions {
6764 root: dispatch.root,
6765 config_path: &cli.config,
6766 output,
6767 json_style: dispatch.json_style,
6768 no_cache: cli.no_cache,
6769 threads: dispatch.threads,
6770 quiet,
6771 allow_remote_extends: cli.allow_remote_extends,
6772 overrides: args.overrides,
6773 top: args.top,
6774 baseline_path: cli.baseline.as_deref(),
6775 baseline_flag: "--baseline",
6776 save_baseline_path: cli.save_baseline.as_deref(),
6777 fail_on_stale_baseline: cli.fail_on_stale_baseline,
6778 fail_on_issues: dispatch.fail_on_issues,
6779 production,
6780 production_override: Some(production),
6781 trace: args.trace.as_deref(),
6782 changed_since: cli.changed_since.as_deref(),
6783 diff_index: None,
6784 use_shared_diff_index: true,
6785 changed_files: None,
6786 change_scope_owner: fallow_engine::change_scope::ChangeScopeOwner::Run,
6787 no_package_baselines: cli.no_package_baselines,
6788 workspace: cli.workspace.as_deref(),
6789 changed_workspaces: cli.changed_workspaces.as_deref(),
6790 explain: cli.explain,
6791 explain_skipped: cli.explain_skipped,
6792 summary: cli.summary,
6793 group_by: cli.group_by,
6794 performance: cli.performance,
6795 include_fragments: !args.no_fragments,
6796 retain_unfiltered_report: false,
6797 scope: args.scope.clone(),
6798 })
6799}
6800
6801struct AuditDispatchArgs {
6802 production_dead_code: bool,
6803 production_health: bool,
6804 production_dupes: bool,
6805 dead_code_baseline: Option<PathBuf>,
6806 health_baseline: Option<PathBuf>,
6807 dupes_baseline: Option<PathBuf>,
6808 max_crap: Option<f64>,
6809 coverage: Option<PathBuf>,
6810 coverage_root: Option<PathBuf>,
6811 no_css: bool,
6812 css_deep: bool,
6813 no_css_deep: bool,
6814 gate: Option<AuditGateArg>,
6815 runtime_coverage: Option<PathBuf>,
6816 min_invocations_hot: u64,
6817 gate_marker: Option<String>,
6818 brief: bool,
6819 max_decisions: usize,
6820 walkthrough_guide: bool,
6822 walkthrough_file: Option<PathBuf>,
6825 walkthrough: bool,
6827 mark_viewed: Vec<PathBuf>,
6829 show_cleared: bool,
6831 show_deprioritized: bool,
6833 scope: Option<PathBuf>,
6834}
6835
6836struct ResolvedAuditInputs {
6837 audit_cfg: fallow_config::AuditConfig,
6838 cache_dir: PathBuf,
6839 production: ProductionModes,
6840 dead_code_baseline: Option<PathBuf>,
6841 health_baseline: Option<PathBuf>,
6842 dupes_baseline: Option<PathBuf>,
6843 coverage: Option<PathBuf>,
6847 coverage_root: Option<PathBuf>,
6848}
6849
6850fn dispatch_audit(dispatch: &DispatchContext<'_>, args: &AuditDispatchArgs) -> ExitCode {
6851 let cli = dispatch.cli;
6852 let output = dispatch.output;
6853
6854 if cli.baseline.is_some() || cli.save_baseline.is_some() {
6855 return emit_error(
6856 "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>`)",
6857 2,
6858 output,
6859 );
6860 }
6861
6862 let inputs = match resolve_audit_inputs(dispatch, args) {
6863 Ok(inputs) => inputs,
6864 Err(code) => return code,
6865 };
6866
6867 if cli.fail_on_baseline_growth && audit_renders_brief(args) {
6870 baseline_growth::note_stood_down(
6871 dispatch.growth_flags(),
6872 "the review brief never fails a run",
6873 );
6874 return run_resolved_audit(dispatch, args, &inputs);
6875 }
6876 let growth_targets = baseline_growth::targets(&[
6877 (
6878 inputs.dead_code_baseline.as_deref(),
6879 fallow_engine::baseline::BaselineKind::DeadCode,
6880 ),
6881 (
6882 inputs.health_baseline.as_deref(),
6883 fallow_engine::baseline::BaselineKind::Health,
6884 ),
6885 (
6886 inputs.dupes_baseline.as_deref(),
6887 fallow_engine::baseline::BaselineKind::Dupes,
6888 ),
6889 ]);
6890 baseline_growth::run_gated(
6891 &dispatch.growth_ctx(baseline_growth::GrowthOwner::Audit),
6892 &growth_targets,
6893 || run_resolved_audit(dispatch, args, &inputs),
6894 )
6895}
6896
6897const fn audit_renders_brief(args: &AuditDispatchArgs) -> bool {
6898 args.brief || args.walkthrough_guide || args.walkthrough || args.walkthrough_file.is_some()
6899}
6900
6901fn resolve_audit_inputs(
6902 dispatch: &DispatchContext<'_>,
6903 args: &AuditDispatchArgs,
6904) -> Result<ResolvedAuditInputs, ExitCode> {
6905 let cli = dispatch.cli;
6906 let root = dispatch.root;
6907 let output = dispatch.output;
6908 let config = load_config(
6909 root,
6910 &cli.config,
6911 LoadConfigArgs {
6912 output,
6913 no_cache: cli.no_cache,
6914 threads: dispatch.threads,
6915 production: cli.production,
6916 quiet: dispatch.quiet,
6917 allow_remote_extends: cli.allow_remote_extends,
6918 },
6919 )?;
6920 let cache_dir = config.cache_dir.clone();
6921 let audit_cfg = config.audit;
6922 let production = resolve_production_modes(
6923 cli,
6924 root,
6925 output,
6926 args.production_dead_code,
6927 args.production_health,
6928 args.production_dupes,
6929 )?;
6930 let resolved_dead_code_baseline = resolve_audit_baseline_path(
6931 root,
6932 args.dead_code_baseline.as_deref(),
6933 audit_cfg.dead_code_baseline.as_deref(),
6934 );
6935 let resolved_health_baseline = resolve_audit_baseline_path(
6936 root,
6937 args.health_baseline.as_deref(),
6938 audit_cfg.health_baseline.as_deref(),
6939 );
6940 let resolved_dupes_baseline = resolve_audit_baseline_path(
6941 root,
6942 args.dupes_baseline.as_deref(),
6943 audit_cfg.dupes_baseline.as_deref(),
6944 );
6945 let coverage_inputs = resolve_coverage_inputs(
6946 args.coverage.as_deref(),
6947 args.coverage_root.as_deref(),
6948 output,
6949 || Ok(config.health),
6950 )?;
6951
6952 Ok(ResolvedAuditInputs {
6953 audit_cfg,
6954 cache_dir,
6955 production,
6956 dead_code_baseline: resolved_dead_code_baseline,
6957 health_baseline: resolved_health_baseline,
6958 dupes_baseline: resolved_dupes_baseline,
6959 coverage: coverage_inputs.coverage,
6960 coverage_root: coverage_inputs.coverage_root,
6961 })
6962}
6963
6964fn audit_css_enabled(config: &fallow_config::AuditConfig, args: &AuditDispatchArgs) -> bool {
6965 !args.no_css && config.css.unwrap_or(true)
6966}
6967
6968fn audit_css_deep_enabled(config: &fallow_config::AuditConfig, args: &AuditDispatchArgs) -> bool {
6969 audit_css_enabled(config, args)
6970 && !args.no_css_deep
6971 && (args.css_deep || config.css_deep.unwrap_or(true))
6972}
6973
6974fn run_resolved_audit(
6975 dispatch: &DispatchContext<'_>,
6976 args: &AuditDispatchArgs,
6977 inputs: &ResolvedAuditInputs,
6978) -> ExitCode {
6979 let cli = dispatch.cli;
6980 audit::run_audit_with_type_aware(
6981 &audit::AuditOptions {
6982 root: dispatch.root,
6983 config_path: &cli.config,
6984 cache_dir: &inputs.cache_dir,
6985 output: dispatch.output,
6986 json_style: dispatch.json_style,
6987 no_cache: cli.no_cache,
6988 threads: dispatch.threads,
6989 quiet: dispatch.quiet,
6990 allow_remote_extends: cli.allow_remote_extends,
6991 changed_since: cli.changed_since.as_deref(),
6992 production: cli.production,
6993 production_dead_code: Some(inputs.production.dead_code),
6994 production_health: Some(inputs.production.health),
6995 production_dupes: Some(inputs.production.dupes),
6996 workspace: cli.workspace.as_deref(),
6997 changed_workspaces: cli.changed_workspaces.as_deref(),
6998 explain: cli.explain,
6999 explain_skipped: cli.explain_skipped,
7000 performance: cli.performance,
7001 group_by: cli.group_by,
7002 dead_code_baseline: inputs.dead_code_baseline.as_deref(),
7003 health_baseline: inputs.health_baseline.as_deref(),
7004 dupes_baseline: inputs.dupes_baseline.as_deref(),
7005 health_baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
7006 fail_on_stale_baseline: cli.fail_on_stale_baseline,
7007 max_crap: args.max_crap,
7008 coverage: inputs.coverage.as_deref(),
7009 coverage_root: inputs.coverage_root.as_deref(),
7010 gate: args.gate.map_or(inputs.audit_cfg.gate, Into::into),
7011 include_entry_exports: cli.include_entry_exports,
7012 show_cascade: cli.show_cascade,
7013 fail_on_parse_error: cli.fail_on_parse_error,
7014 css: audit_css_enabled(&inputs.audit_cfg, args),
7018 css_deep: audit_css_deep_enabled(&inputs.audit_cfg, args),
7019 runtime_coverage: args.runtime_coverage.as_deref(),
7020 min_invocations_hot: args.min_invocations_hot,
7021 brief: args.brief,
7022 max_decisions: args.max_decisions,
7023 walkthrough_guide: args.walkthrough_guide,
7024 walkthrough: args.walkthrough,
7025 mark_viewed: &args.mark_viewed,
7026 show_cleared: args.show_cleared,
7027 walkthrough_file: args.walkthrough_file.as_deref(),
7028 show_deprioritized: args.show_deprioritized,
7029 scope: args.scope.clone(),
7030 },
7031 args.gate_marker.as_deref(),
7032 audit::AuditTypeAwareOptions {
7033 enabled: cli.type_aware_override(),
7034 config_default: inputs.audit_cfg.type_aware,
7035 projects: &cli.type_aware_project,
7036 require: cli.type_aware_require.map(Into::into),
7037 },
7038 )
7039}
7040
7041fn dispatch_decision_surface(dispatch: &DispatchContext<'_>, max_decisions: usize) -> ExitCode {
7045 let args = decision_surface_audit_args(max_decisions);
7046 let inputs = match resolve_audit_inputs(dispatch, &args) {
7047 Ok(inputs) => inputs,
7048 Err(code) => return code,
7049 };
7050 if dispatch.cli.fail_on_stale_baseline {
7054 for path in [
7055 &inputs.dead_code_baseline,
7056 &inputs.health_baseline,
7057 &inputs.dupes_baseline,
7058 ] {
7059 baseline_gate::note_stood_down(
7060 path.as_deref(),
7061 true,
7062 "decision-surface renders a brief without exit gates",
7063 );
7064 }
7065 }
7066 audit::run_decision_surface(&decision_surface_audit_options(
7067 dispatch,
7068 &inputs,
7069 max_decisions,
7070 ))
7071}
7072
7073fn decision_surface_audit_args(max_decisions: usize) -> AuditDispatchArgs {
7074 AuditDispatchArgs {
7075 production_dead_code: false,
7076 production_health: false,
7077 production_dupes: false,
7078 dead_code_baseline: None,
7079 health_baseline: None,
7080 dupes_baseline: None,
7081 max_crap: None,
7082 coverage: None,
7083 coverage_root: None,
7084 no_css: true,
7085 css_deep: false,
7086 no_css_deep: false,
7087 gate: None,
7088 runtime_coverage: None,
7089 min_invocations_hot: 0,
7090 gate_marker: None,
7091 brief: true,
7092 max_decisions,
7093 walkthrough_guide: false,
7094 walkthrough_file: None,
7095 walkthrough: false,
7096 mark_viewed: Vec::new(),
7097 show_cleared: false,
7098 show_deprioritized: false,
7099 scope: None,
7100 }
7101}
7102
7103fn decision_surface_audit_options<'a>(
7104 dispatch: &'a DispatchContext<'a>,
7105 inputs: &'a ResolvedAuditInputs,
7106 max_decisions: usize,
7107) -> audit::AuditOptions<'a> {
7108 let cli = dispatch.cli;
7109 audit::AuditOptions {
7110 root: dispatch.root,
7111 config_path: &cli.config,
7112 cache_dir: &inputs.cache_dir,
7113 output: dispatch.output,
7114 json_style: dispatch.json_style,
7115 no_cache: cli.no_cache,
7116 threads: dispatch.threads,
7117 quiet: dispatch.quiet,
7118 allow_remote_extends: cli.allow_remote_extends,
7119 changed_since: cli.changed_since.as_deref(),
7120 production: cli.production,
7121 production_dead_code: Some(inputs.production.dead_code),
7122 production_health: Some(inputs.production.health),
7123 production_dupes: Some(inputs.production.dupes),
7124 workspace: cli.workspace.as_deref(),
7125 changed_workspaces: cli.changed_workspaces.as_deref(),
7126 explain: cli.explain,
7127 explain_skipped: cli.explain_skipped,
7128 performance: cli.performance,
7129 group_by: cli.group_by,
7130 dead_code_baseline: inputs.dead_code_baseline.as_deref(),
7131 health_baseline: inputs.health_baseline.as_deref(),
7132 dupes_baseline: inputs.dupes_baseline.as_deref(),
7133 health_baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
7134 fail_on_stale_baseline: false,
7138 max_crap: None,
7139 coverage: None,
7140 coverage_root: None,
7141 gate: inputs.audit_cfg.gate,
7142 include_entry_exports: cli.include_entry_exports,
7143 show_cascade: cli.show_cascade,
7144 fail_on_parse_error: false,
7145 css: false,
7147 css_deep: false,
7148 runtime_coverage: None,
7149 min_invocations_hot: 0,
7150 brief: true,
7151 max_decisions,
7152 walkthrough_guide: false,
7153 walkthrough: false,
7154 mark_viewed: &[],
7155 show_cleared: false,
7156 walkthrough_file: None,
7157 show_deprioritized: false,
7158 scope: None,
7159 }
7160}
7161
7162struct HealthDispatchArgs<'a> {
7163 max_cyclomatic: Option<u16>,
7164 max_cognitive: Option<u16>,
7165 max_crap: Option<f64>,
7166 top: Option<usize>,
7167 sort: health::SortBy,
7168 complexity: bool,
7169 complexity_breakdown: bool,
7170 file_scores: bool,
7171 coverage_gaps: bool,
7172 hotspots: bool,
7173 ownership: bool,
7174 ownership_emails: Option<fallow_config::EmailMode>,
7175 targets: bool,
7176 type_coupling: bool,
7177 css: bool,
7178 effort: Option<EffortFilter>,
7179 score: bool,
7180 min_score: Option<f64>,
7181 min_severity: Option<fallow_output::FindingSeverity>,
7182 report_only: bool,
7183 fail_on_stale_baseline: bool,
7184 fail_on_parse_error: bool,
7185 since: Option<&'a str>,
7186 min_commits: Option<u32>,
7187 save_snapshot: Option<&'a Option<String>>,
7188 trend: bool,
7189 trend_from: Option<&'a std::path::Path>,
7190 coverage: Option<&'a std::path::Path>,
7191 coverage_root: Option<&'a std::path::Path>,
7192 runtime_coverage: Option<&'a std::path::Path>,
7193 min_invocations_hot: u64,
7194 min_observation_volume: Option<u32>,
7195 low_traffic_threshold: Option<f64>,
7196 scope: Option<std::path::PathBuf>,
7197}
7198
7199type ResolvedHealthCoverageInputs = fallow_api::CoverageInputs;
7200
7201fn resolve_coverage_inputs(
7212 cli_coverage: Option<&std::path::Path>,
7213 cli_coverage_root: Option<&std::path::Path>,
7214 output: fallow_config::OutputFormat,
7215 config_health: impl FnOnce() -> Result<fallow_config::HealthConfig, ExitCode>,
7216) -> Result<ResolvedHealthCoverageInputs, ExitCode> {
7217 let explicit = fallow_api::CoverageInputs {
7218 coverage: cli_coverage.map(std::path::Path::to_path_buf),
7219 coverage_root: cli_coverage_root.map(std::path::Path::to_path_buf),
7220 };
7221 let env = fallow_api::CoverageInputs {
7222 coverage: path_from_env("FALLOW_COVERAGE"),
7223 coverage_root: path_from_env("FALLOW_COVERAGE_ROOT"),
7224 };
7225 let config_health = if fallow_api::CoverageInputs::needs_config_layer(&explicit, &env) {
7226 Some(config_health()?)
7227 } else {
7228 None
7229 };
7230
7231 fallow_api::resolve_coverage_inputs(explicit, env, config_health.as_ref())
7232 .map_err(|err| emit_error(&err.to_string(), 2, output))
7233}
7234
7235fn resolve_health_coverage_inputs(
7238 dispatch: &DispatchContext<'_>,
7239 cli_coverage: Option<&std::path::Path>,
7240 cli_coverage_root: Option<&std::path::Path>,
7241) -> Result<ResolvedHealthCoverageInputs, ExitCode> {
7242 resolve_coverage_inputs(cli_coverage, cli_coverage_root, dispatch.output, || {
7243 Ok(load_config(
7244 dispatch.root,
7245 &dispatch.cli.config,
7246 LoadConfigArgs {
7247 output: dispatch.output,
7248 no_cache: dispatch.cli.no_cache,
7249 threads: dispatch.threads,
7250 production: dispatch.cli.production,
7251 quiet: dispatch.quiet,
7252 allow_remote_extends: dispatch.cli.allow_remote_extends,
7253 },
7254 )?
7255 .health)
7256 })
7257}
7258
7259fn path_from_env(name: &str) -> Option<PathBuf> {
7260 std::env::var_os(name)
7261 .filter(|value| !value.is_empty())
7262 .map(PathBuf::from)
7263}
7264
7265fn validate_health_report_only_gate(
7266 report_only: bool,
7267 min_score: Option<f64>,
7268 min_severity: Option<fallow_output::FindingSeverity>,
7269 output: fallow_config::OutputFormat,
7270) -> Result<(), ExitCode> {
7271 if report_only && (min_score.is_some() || min_severity.is_some()) {
7272 return Err(emit_error(
7273 "--report-only cannot be combined with --min-score or --min-severity. \
7274 --report-only always exits 0; drop it to gate on score/severity, or \
7275 drop the gate flags to stay advisory.",
7276 2,
7277 output,
7278 ));
7279 }
7280
7281 Ok(())
7282}
7283
7284fn resolve_runtime_coverage_options(
7285 runtime_coverage: Option<&std::path::Path>,
7286 min_invocations_hot: u64,
7287 min_observation_volume: Option<u32>,
7288 low_traffic_threshold: Option<f64>,
7289 output: fallow_config::OutputFormat,
7290) -> Result<Option<fallow_engine::health::RuntimeCoverageOptions>, ExitCode> {
7291 let Some(path) = runtime_coverage else {
7292 return Ok(None);
7293 };
7294
7295 health::coverage::prepare_options(
7296 path,
7297 min_invocations_hot,
7298 min_observation_volume,
7299 low_traffic_threshold,
7300 output,
7301 )
7302 .map(Some)
7303}
7304
7305fn dispatch_health(dispatch: &DispatchContext<'_>, args: &HealthDispatchArgs<'_>) -> ExitCode {
7306 let cli = dispatch.cli;
7307 let root = dispatch.root;
7308 let (output, _quiet, _fail_on_issues) =
7309 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
7310 if let Err(code) = validate_health_report_only_gate(
7311 args.report_only,
7312 args.min_score,
7313 args.min_severity,
7314 output,
7315 ) {
7316 return code;
7317 }
7318 let runtime_coverage = match resolve_runtime_coverage_options(
7319 args.runtime_coverage,
7320 args.min_invocations_hot,
7321 args.min_observation_volume,
7322 args.low_traffic_threshold,
7323 output,
7324 ) {
7325 Ok(options) => options,
7326 Err(code) => return code,
7327 };
7328 let production = match resolve_production_modes(cli, root, output, false, false, false) {
7329 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::Health),
7330 Err(code) => return code,
7331 };
7332 let coverage_inputs =
7333 match resolve_health_coverage_inputs(dispatch, args.coverage, args.coverage_root) {
7334 Ok(inputs) => inputs,
7335 Err(code) => return code,
7336 };
7337 let run = derive_health_dispatch_run(args, output, &coverage_inputs, runtime_coverage);
7338 let resolved = ResolvedHealthDispatch { run, production };
7339 if args.report_only && cli.fail_on_baseline_growth {
7342 baseline_growth::note_stood_down(
7343 dispatch.growth_flags(),
7344 "health --report-only never fails a run",
7345 );
7346 return run_health_dispatch(dispatch, args, resolved);
7347 }
7348 let growth_targets = baseline_growth::targets(&[(
7349 cli.baseline.as_deref(),
7350 fallow_engine::baseline::BaselineKind::Health,
7351 )]);
7352 baseline_growth::run_gated(
7353 &dispatch.growth_ctx(baseline_growth::GrowthOwner::Health),
7354 &growth_targets,
7355 || run_health_dispatch(dispatch, args, resolved),
7356 )
7357}
7358
7359fn derive_health_dispatch_run<'a>(
7360 args: &'a HealthDispatchArgs<'a>,
7361 output: fallow_config::OutputFormat,
7362 coverage_inputs: &'a ResolvedHealthCoverageInputs,
7363 runtime_coverage: Option<fallow_engine::health::RuntimeCoverageOptions>,
7364) -> fallow_engine::health::HealthRunOptions<'a> {
7365 let mut run = fallow_engine::health::derive_health_run_options(
7366 fallow_engine::health::HealthRunOptionsInput {
7367 output,
7368 thresholds: health_threshold_overrides(args),
7369 top: args.top,
7370 sort: args.sort.clone().into(),
7371 complexity: args.complexity,
7372 file_scores: args.file_scores,
7373 coverage_gaps: args.coverage_gaps,
7374 hotspots: args.hotspots,
7375 ownership: args.ownership,
7376 ownership_emails: args.ownership_emails,
7377 targets: args.targets,
7378 css: args.css,
7379 effort: args.effort.map(EffortFilter::to_estimate),
7380 score: args.score,
7381 gates: health_gate_options(args),
7382 snapshot_requested: args.save_snapshot.is_some(),
7383 trend: args.trend,
7384 since: args.since,
7385 min_commits: args.min_commits,
7386 coverage_inputs: health_coverage_inputs(coverage_inputs),
7387 runtime_coverage,
7388 },
7389 );
7390 if args.type_coupling && !run.sections.any_section {
7391 run.sections = fallow_engine::health::DerivedHealthSections {
7392 any_section: true,
7393 complexity: false,
7394 file_scores: false,
7395 coverage_gaps: false,
7396 hotspots: false,
7397 targets: false,
7398 css: false,
7399 score: false,
7400 force_full: false,
7401 score_only_output: false,
7402 };
7403 }
7404 run
7405}
7406
7407fn health_threshold_overrides(
7408 args: &HealthDispatchArgs<'_>,
7409) -> fallow_engine::health::HealthThresholdOverrides {
7410 fallow_engine::health::HealthThresholdOverrides {
7411 max_cyclomatic: args.max_cyclomatic,
7412 max_cognitive: args.max_cognitive,
7413 max_crap: args.max_crap,
7414 }
7415}
7416
7417fn health_gate_options(args: &HealthDispatchArgs<'_>) -> fallow_engine::health::HealthGateOptions {
7418 fallow_engine::health::HealthGateOptions {
7419 min_score: args.min_score,
7420 min_severity: args.min_severity,
7421 report_only: args.report_only,
7422 fail_on_stale_baseline: args.fail_on_stale_baseline,
7423 fail_on_parse_error: args.fail_on_parse_error,
7424 fail_on_issues: false,
7425 }
7426}
7427
7428fn health_coverage_inputs(
7429 coverage_inputs: &ResolvedHealthCoverageInputs,
7430) -> fallow_engine::health::HealthCoverageInputs<'_> {
7431 fallow_engine::health::HealthCoverageInputs {
7432 coverage: coverage_inputs.coverage.as_deref(),
7433 coverage_root: coverage_inputs.coverage_root.as_deref(),
7434 coverage_relocated: false,
7435 }
7436}
7437
7438struct ResolvedHealthDispatch<'a> {
7442 run: fallow_engine::health::HealthRunOptions<'a>,
7443 production: bool,
7444}
7445
7446fn run_health_dispatch(
7449 dispatch: &DispatchContext<'_>,
7450 args: &HealthDispatchArgs<'_>,
7451 resolved: ResolvedHealthDispatch<'_>,
7452) -> ExitCode {
7453 let cli = dispatch.cli;
7454 let (output, quiet, fail_on_issues) =
7455 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
7456 let mut run = resolved.run;
7457 run.gates.fail_on_issues = fail_on_issues;
7458 let sections = run.sections;
7459 let production = resolved.production;
7460 health::run_health(
7461 &HealthOptions {
7462 root: dispatch.root,
7463 config_path: &cli.config,
7464 output,
7465 no_cache: cli.no_cache,
7466 threads: dispatch.threads,
7467 quiet,
7468 thresholds: run.thresholds,
7469 top: run.top,
7470 sort: run.sort,
7471 production,
7472 production_override: Some(production),
7473 allow_remote_extends: cli.allow_remote_extends,
7474 changed_since: cli.changed_since.as_deref(),
7475 diff_index: None,
7476 use_shared_diff_index: true,
7477 workspace: cli.workspace.as_deref(),
7478 changed_workspaces: cli.changed_workspaces.as_deref(),
7479 baseline: cli.baseline.as_deref(),
7480 save_baseline: cli.save_baseline.as_deref(),
7481 baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
7482 baseline_mode_explicit: cli.baseline_mode.is_some(),
7483 complexity: sections.complexity,
7484 file_scores: sections.file_scores,
7485 coverage_gaps: sections.coverage_gaps,
7486 config_activates_coverage_gaps: !sections.any_section,
7487 hotspots: sections.hotspots,
7488 ownership: run.ownership,
7489 ownership_emails: run.ownership_emails,
7490 targets: sections.targets,
7491 css: sections.css,
7492 css_deep: false,
7493 force_full: sections.force_full,
7494 score_only_output: sections.score_only_output,
7495 enforce_coverage_gap_gate: true,
7496 effort: run.effort,
7497 score: sections.score,
7498 gates: run.gates,
7499 since: run.since,
7500 min_commits: run.min_commits,
7501 explain: cli.explain,
7502 summary: cli.summary,
7503 save_snapshot: args
7504 .save_snapshot
7505 .map(|opt| PathBuf::from(opt.as_deref().unwrap_or_default())),
7506 trend: args.trend,
7507 coverage_inputs: run.coverage_inputs,
7508 performance: cli.performance,
7509 runtime_coverage: run.runtime_coverage,
7510 churn_file: cli.churn_file.as_deref(),
7511 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
7512 complexity_breakdown: args.complexity_breakdown,
7513 group_by: cli.group_by.map(Into::into),
7514 group_filter: cli.group.as_deref(),
7515 trend_from: args.trend_from,
7516 scope: args.scope.clone(),
7517 },
7518 dispatch.json_style,
7519 &health::TypeAwareHealthOptions {
7520 enabled: cli.type_aware_override(),
7521 requested: args.type_coupling,
7522 unfiltered: health_type_coupling_is_default_section(args),
7523 projects: &cli.type_aware_project,
7524 require: cli.type_aware_require.map(Into::into),
7525 },
7526 )
7527}
7528
7529fn health_type_coupling_is_default_section(args: &HealthDispatchArgs<'_>) -> bool {
7530 !args.complexity
7531 && !args.file_scores
7532 && !args.coverage_gaps
7533 && !args.hotspots
7534 && !args.ownership
7535 && !args.targets
7536 && !args.css
7537 && !args.score
7538 && args.min_score.is_none()
7539 && args.min_severity.is_none()
7540 && args.runtime_coverage.is_none()
7541}
7542
7543#[cfg(test)]
7544mod tests {
7545 use super::*;
7546
7547 #[test]
7551 fn cli_definition_has_no_flag_collisions() {
7552 use clap::CommandFactory;
7553 Cli::command().debug_assert();
7554 }
7555
7556 #[test]
7557 fn impact_statusline_subcommand_parses() {
7558 use clap::Parser;
7559
7560 let cli = Cli::try_parse_from(["fallow", "impact", "statusline"]).expect("argv parses");
7561 assert!(matches!(
7562 cli.command,
7563 Some(Command::Impact {
7564 subcommand: Some(ImpactCli::Statusline),
7565 ..
7566 })
7567 ));
7568 }
7569
7570 #[test]
7571 fn impact_statusline_bypasses_command_epilogue() {
7572 use clap::Parser;
7573
7574 let statusline =
7575 Cli::try_parse_from(["fallow", "impact", "statusline"]).expect("argv parses");
7576 assert!(is_impact_statusline(&statusline));
7577
7578 let status = Cli::try_parse_from(["fallow", "impact", "status"]).expect("argv parses");
7579 assert!(!is_impact_statusline(&status));
7580
7581 let all_statusline =
7582 Cli::try_parse_from(["fallow", "impact", "--all", "statusline"]).expect("argv parses");
7583 assert!(!is_impact_statusline(&all_statusline));
7584 }
7585
7586 #[test]
7587 fn regression_baseline_help_explains_the_default_destination() {
7588 use clap::CommandFactory;
7589 let help = Cli::command().render_long_help().to_string();
7590
7591 assert!(help.contains("Omit PATH to update regression.baseline"));
7592 assert!(help.contains("discovered fallow config"));
7593 assert!(help.contains("create .fallowrc.json when none exists"));
7594 }
7595
7596 #[test]
7600 fn after_help_lists_every_task_matrix_command() {
7601 for row in crate::task_matrix::TASK_MATRIX {
7602 assert!(
7603 TOP_LEVEL_AFTER_LONG_HELP.contains(row.command),
7604 "root --help cheat sheet is missing task-matrix command '{}'; \
7605 update the top_level_task_cheat_sheet! fragment to match TASK_MATRIX",
7606 row.command
7607 );
7608 }
7609 }
7610
7611 #[test]
7618 fn after_help_lists_every_visible_subcommand() {
7619 use clap::CommandFactory;
7620
7621 for sub in Cli::command().get_subcommands() {
7622 if sub.is_hide_set() {
7623 continue;
7624 }
7625 let name = sub.get_name();
7626 let listed = TOP_LEVEL_AFTER_LONG_HELP
7627 .lines()
7628 .any(|line| line.split_whitespace().next() == Some(name));
7629 assert!(
7630 listed,
7631 "root --help command list is missing subcommand '{name}'; \
7632 add it to a top_level_*_command_groups! section"
7633 );
7634 }
7635 }
7636
7637 #[test]
7641 fn every_visible_subcommand_is_classified_for_its_machine_contract() {
7642 use clap::CommandFactory;
7643 use fallow_types::command_surfaces::{
7644 COMMAND_ENVELOPES, COMMANDS_WITHOUT_ANALYSIS_ENVELOPE,
7645 };
7646
7647 let visible: Vec<String> = Cli::command()
7648 .get_subcommands()
7649 .filter(|sub| !sub.is_hide_set() && sub.get_name() != "help")
7650 .map(|sub| sub.get_name().to_string())
7651 .collect();
7652 for name in &visible {
7653 let rows = COMMAND_ENVELOPES
7654 .iter()
7655 .filter(|row| row.command == name)
7656 .count();
7657 let others = COMMANDS_WITHOUT_ANALYSIS_ENVELOPE
7658 .iter()
7659 .filter(|(command, _)| command == name)
7660 .count();
7661 assert_eq!(
7662 rows + others,
7663 1,
7664 "subcommand `{name}` must be in exactly one of COMMAND_ENVELOPES and \
7665 COMMANDS_WITHOUT_ANALYSIS_ENVELOPE (crates/types/src/command_surfaces.rs)"
7666 );
7667 }
7668 let listed = COMMAND_ENVELOPES.iter().map(|row| row.command).chain(
7669 COMMANDS_WITHOUT_ANALYSIS_ENVELOPE
7670 .iter()
7671 .map(|(command, _)| *command),
7672 );
7673 for command in listed {
7674 assert!(
7675 visible.iter().any(|name| name == command),
7676 "`{command}` is listed in command_surfaces.rs but is not a visible subcommand"
7677 );
7678 }
7679 }
7680
7681 #[test]
7684 fn architecture_flags_map_to_the_dead_code_structure_filters() {
7685 use clap::Parser;
7686
7687 let structure = |argv: &[&str]| {
7688 let cli = Cli::try_parse_from(argv).expect("argv parses");
7689 check_issue_filters(&cli.command.expect("subcommand"))
7690 };
7691 let architecture =
7692 |cycles, boundaries, policy| architecture_issue_filters(cycles, boundaries, policy);
7693 assert_eq!(
7694 architecture(false, false, false),
7695 structure(&[
7696 "fallow",
7697 "dead-code",
7698 "--circular-deps",
7699 "--re-export-cycles",
7700 "--package-cycles",
7701 "--boundary-violations",
7702 "--policy-violations",
7703 ])
7704 );
7705 assert_eq!(
7706 architecture(true, false, false),
7707 structure(&[
7708 "fallow",
7709 "dead-code",
7710 "--circular-deps",
7711 "--re-export-cycles",
7712 "--package-cycles",
7713 ])
7714 );
7715 assert_eq!(
7716 architecture(false, true, false),
7717 structure(&["fallow", "dead-code", "--boundary-violations"])
7718 );
7719 assert_eq!(
7720 architecture(false, false, true),
7721 structure(&["fallow", "dead-code", "--policy-violations"])
7722 );
7723 assert!(
7724 Cli::try_parse_from([
7725 "fallow",
7726 "architecture",
7727 "--cycles",
7728 "--boundaries",
7729 "--policy"
7730 ])
7731 .is_ok()
7732 );
7733 }
7734
7735 #[test]
7739 fn short_help_stays_scannable_with_cheat_sheet_and_pointer() {
7740 use clap::CommandFactory;
7741
7742 let help = Cli::command().render_help().to_string();
7743 let lines = help.lines().count();
7744 assert!(
7745 lines < 90,
7746 "root -h grew to {lines} lines; keep the short surface under 90 \
7747 (curate hide_short_help and the short after-help instead)"
7748 );
7749 assert!(help.contains("When the agent is about to..."));
7750 assert!(help.contains("Run fallow --help for the complete command list."));
7751 }
7752
7753 #[test]
7757 fn high_value_commands_route_to_distinct_workflows() {
7758 use clap::Parser;
7759 use fallow_config::OutputFormat;
7760
7761 let distinct = [
7762 (vec!["fallow", "impact"], telemetry::Workflow::Impact),
7763 (vec!["fallow", "security"], telemetry::Workflow::Security),
7764 (vec!["fallow", "fix"], telemetry::Workflow::Fix),
7765 (
7766 vec!["fallow", "explain", "unused-exports"],
7767 telemetry::Workflow::Explain,
7768 ),
7769 (
7770 vec!["fallow", "watch"],
7771 telemetry::Workflow::CodeQualityReview,
7772 ),
7773 (
7774 vec!["fallow", "list"],
7775 telemetry::Workflow::ProjectInventory,
7776 ),
7777 (
7778 vec!["fallow", "workspaces"],
7779 telemetry::Workflow::ProjectInventory,
7780 ),
7781 (
7782 vec!["fallow", "schema"],
7783 telemetry::Workflow::ProjectInventory,
7784 ),
7785 (vec!["fallow", "init"], telemetry::Workflow::Setup),
7786 (
7787 vec!["fallow", "hooks", "install", "--target", "git"],
7788 telemetry::Workflow::Setup,
7789 ),
7790 (vec!["fallow", "config-schema"], telemetry::Workflow::Setup),
7791 (vec!["fallow", "plugin-schema"], telemetry::Workflow::Setup),
7792 (
7793 vec!["fallow", "rule-pack-schema"],
7794 telemetry::Workflow::Setup,
7795 ),
7796 (vec!["fallow", "config"], telemetry::Workflow::Setup),
7797 (
7798 vec!["fallow", "ci-template", "gitlab"],
7799 telemetry::Workflow::Setup,
7800 ),
7801 (vec!["fallow", "migrate"], telemetry::Workflow::Setup),
7802 (
7803 vec!["fallow", "telemetry", "status"],
7804 telemetry::Workflow::Setup,
7805 ),
7806 (vec!["fallow", "setup-hooks"], telemetry::Workflow::Setup),
7807 (
7808 vec!["fallow", "audit-cache", "remove", "--root", "."],
7809 telemetry::Workflow::Setup,
7810 ),
7811 (
7812 vec!["fallow", "license", "status"],
7813 telemetry::Workflow::License,
7814 ),
7815 (
7816 vec!["fallow", "architecture", "--cycles"],
7817 telemetry::Workflow::Architecture,
7818 ),
7819 ];
7820 for (argv, expected) in distinct {
7821 let cli = Cli::try_parse_from(&argv).expect("argv parses");
7822 assert_eq!(
7823 telemetry_workflow_for_command(cli.command.as_ref(), OutputFormat::Json),
7824 expected,
7825 "{argv:?} should map to {expected:?}"
7826 );
7827 }
7828 }
7829
7830 #[test]
7835 fn version_flag_accepts_lower_v_upper_v_and_long() {
7836 use clap::CommandFactory;
7837 for argv in [["fallow", "-v"], ["fallow", "-V"], ["fallow", "--version"]] {
7838 let err = Cli::command()
7839 .try_get_matches_from(argv)
7840 .expect_err("version flag should short-circuit parsing");
7841 assert_eq!(
7842 err.kind(),
7843 clap::error::ErrorKind::DisplayVersion,
7844 "{argv:?} should trigger the Version action"
7845 );
7846 }
7847 }
7848
7849 #[test]
7854 fn cli_help_text_contains_no_implementation_status_wording() {
7855 use clap::CommandFactory;
7856 let mut root = Cli::command();
7857 let mut violations: Vec<(String, String)> = Vec::new();
7858 visit_help(&mut root, "fallow", &mut violations);
7859 assert!(
7860 violations.is_empty(),
7861 "found implementation-status wording in --help output:\n{}",
7862 violations
7863 .iter()
7864 .map(|(cmd, line)| format!(" {cmd}: {line}"))
7865 .collect::<Vec<_>>()
7866 .join("\n")
7867 );
7868 }
7869
7870 #[test]
7871 fn dependency_override_help_is_package_manager_neutral() {
7872 use clap::CommandFactory;
7873 let help = Cli::command()
7874 .find_subcommand_mut("dead-code")
7875 .expect("dead-code command")
7876 .render_long_help()
7877 .to_string();
7878
7879 assert!(help.contains("Only report unused package-manager dependency overrides"));
7880 assert!(help.contains("Only report misconfigured package-manager dependency overrides"));
7881 assert!(!help.contains("unused pnpm dependency overrides"));
7882 assert!(!help.contains("misconfigured pnpm dependency overrides"));
7883 }
7884
7885 #[test]
7886 fn top_level_help_groups_commands_by_workflow() {
7887 use clap::CommandFactory;
7888 let help = Cli::command().render_long_help().to_string();
7889 let expected_order = [
7890 "Analysis:",
7891 " dead-code",
7892 " dupes",
7893 " health",
7894 " flags",
7895 " security",
7896 " audit",
7897 "Workflow:",
7898 " watch",
7899 " fix",
7900 "Project inspection:",
7901 " list",
7902 " workspaces",
7903 " explain",
7904 " impact",
7905 " viz",
7906 "Setup and configuration:",
7907 " init",
7908 " recommend",
7909 " migrate",
7910 " config",
7911 " config-schema",
7912 " plugin-schema",
7913 " plugin-check",
7914 " rule-pack-schema",
7915 "Automation and CI:",
7916 " ci",
7917 " ci-template",
7918 " hooks",
7919 " setup-hooks",
7920 "Runtime coverage:",
7921 " coverage",
7922 " license",
7923 "Reference:",
7924 " schema",
7925 " help",
7926 "Options:",
7927 ];
7928 let mut cursor = 0;
7929 for needle in expected_order {
7930 let Some(offset) = help[cursor..].find(needle) else {
7931 panic!("top-level help missing `{needle}` after byte {cursor}:\n{help}");
7932 };
7933 cursor += offset + needle.len();
7934 }
7935 }
7936
7937 #[test]
7941 fn every_global_dupes_override_is_rejected_where_dupes_does_not_run() {
7942 let longs: Vec<String> = <Cli as clap::CommandFactory>::command()
7943 .get_arguments()
7944 .filter_map(|arg| arg.get_long())
7945 .filter(|long| long.starts_with("dupes-") && *long != "dupes-baseline")
7946 .map(str::to_owned)
7947 .collect();
7948 assert!(
7949 longs.len() > 1,
7950 "expected global --dupes-* flags, got {longs:?}"
7951 );
7952 let security_help = render_security_help(SecurityHelpTarget::Parent);
7953
7954 for long in &longs {
7955 let flag = format!("--{long}");
7956 let takes_value = <Cli as clap::CommandFactory>::command()
7957 .get_arguments()
7958 .find(|arg| arg.get_long() == Some(long.as_str()))
7959 .is_some_and(|arg| arg.get_action().takes_values());
7960 let value = if long == "dupes-mode" { "weak" } else { "3" };
7961 let argv_for = |command: &[&str]| {
7962 let mut argv = vec!["fallow"];
7963 argv.extend_from_slice(command);
7964 argv.push(flag.as_str());
7965 if takes_value {
7966 argv.push(value);
7967 }
7968 Cli::try_parse_from(argv).expect("global dupes flag parses")
7969 };
7970
7971 let security = argv_for(&["security"]);
7972 assert_eq!(unsupported_security_global(&security), Some(flag.as_str()));
7973 let doctor = argv_for(&["doctor"]);
7974 assert_eq!(unsupported_doctor_option(&doctor), Some(flag.as_str()));
7975 let similar_code = argv_for(&["similar-code", "status"]);
7976 assert_eq!(
7977 similar_code_help::unsupported_similar_code_option(&similar_code),
7978 Some(flag.as_str())
7979 );
7980 assert!(
7981 !help_contains_long_flag(&security_help, long),
7982 "security help must hide unsupported {flag}:\n{security_help}"
7983 );
7984 }
7985 }
7986
7987 #[test]
7988 fn security_help_hides_globals_rejected_by_security_validator() {
7989 let help = render_security_help(SecurityHelpTarget::Parent);
7990
7991 for long in SECURITY_UNSUPPORTED_GLOBAL_LONGS {
7992 assert!(
7993 !help_contains_long_flag(&help, long),
7994 "security help must hide unsupported --{long}:\n{help}"
7995 );
7996 }
7997
7998 for long in [
7999 "root",
8000 "config",
8001 "format",
8002 "quiet",
8003 "no-cache",
8004 "threads",
8005 "changed-since",
8006 "diff-file",
8007 "diff-stdin",
8008 "workspace",
8009 "changed-workspaces",
8010 "ci",
8011 "fail-on-issues",
8012 "sarif-file",
8013 "summary",
8014 "output-file",
8015 "max-file-size",
8016 "explain",
8017 "surface",
8018 ] {
8019 assert!(
8020 help_contains_long_flag(&help, long),
8021 "security help must keep supported --{long}:\n{help}"
8022 );
8023 }
8024 }
8025
8026 #[test]
8027 fn security_help_detection_covers_subcommand_and_help_alias_forms() {
8028 assert_eq!(
8029 security_help_target(["security", "--help"]),
8030 Some(SecurityHelpTarget::Parent)
8031 );
8032 assert_eq!(
8033 security_help_target(["security", "-h"]),
8034 Some(SecurityHelpTarget::Parent)
8035 );
8036 assert_eq!(
8037 security_help_target(["--format", "json", "security", "--help"]),
8038 Some(SecurityHelpTarget::Parent)
8039 );
8040 assert_eq!(
8041 security_help_target(["help", "security"]),
8042 Some(SecurityHelpTarget::Parent)
8043 );
8044 assert_eq!(
8045 security_help_target(["security", "survivors", "--help"]),
8046 Some(SecurityHelpTarget::Survivors)
8047 );
8048 assert_eq!(
8049 security_help_target(["security", "survivors", "-h"]),
8050 Some(SecurityHelpTarget::Survivors)
8051 );
8052 assert_eq!(
8053 security_help_target(["help", "security", "survivors"]),
8054 Some(SecurityHelpTarget::Survivors)
8055 );
8056 assert_eq!(
8057 security_help_target(["security", "blind-spots", "--help"]),
8058 Some(SecurityHelpTarget::BlindSpots)
8059 );
8060 assert_eq!(
8061 security_help_target(["help", "security", "blind-spots"]),
8062 Some(SecurityHelpTarget::BlindSpots)
8063 );
8064 assert_eq!(security_help_target(["health", "--help"]), None);
8065 assert_eq!(security_help_target(["help", "health"]), None);
8066 }
8067
8068 #[test]
8069 fn security_unsupported_global_validator_matches_hidden_help_contract() {
8070 for (argv, expected) in [
8071 (vec!["fallow", "security", "--performance"], "--performance"),
8072 (
8073 vec!["fallow", "security", "--no-package-baselines"],
8074 "--no-package-baselines",
8075 ),
8076 (
8077 vec!["fallow", "security", "--baseline", "base.json"],
8078 "--baseline",
8079 ),
8080 (
8081 vec!["fallow", "security", "--fail-on-stale-baseline"],
8082 "--fail-on-stale-baseline",
8083 ),
8084 (
8085 vec!["fallow", "security", "--dupes-mode", "weak"],
8086 "--dupes-mode",
8087 ),
8088 (vec!["fallow", "security", "--dupes-near"], "--dupes-near"),
8089 ] {
8090 let cli = Cli::try_parse_from(argv).expect("security global parses before validation");
8091 assert_eq!(unsupported_security_global(&cli), Some(expected));
8092 }
8093
8094 let explain = Cli::try_parse_from(["fallow", "security", "--explain"])
8095 .expect("security --explain parses");
8096 assert_eq!(unsupported_security_global(&explain), None);
8097 }
8098
8099 #[test]
8100 fn programmatic_common_options_track_analysis_affecting_cli_globals() {
8101 use clap::CommandFactory;
8102
8103 let cli_flags: std::collections::BTreeSet<String> = Cli::command()
8104 .get_arguments()
8105 .filter(|arg| arg.is_global_set())
8106 .filter_map(|arg| arg.get_long().map(str::to_owned))
8107 .filter(|name| {
8108 matches!(
8109 name.as_str(),
8110 "root"
8111 | "config"
8112 | "allow-remote-extends"
8113 | "no-cache"
8114 | "threads"
8115 | "changed-since"
8116 | "diff-file"
8117 | "production"
8118 | "workspace"
8119 | "changed-workspaces"
8120 | "explain"
8121 )
8122 })
8123 .collect();
8124 let programmatic_flags: std::collections::BTreeSet<String> =
8125 fallow_api::COMMON_ANALYSIS_OPTION_FLAGS
8126 .iter()
8127 .map(|flag| (*flag).to_owned())
8128 .collect();
8129
8130 assert_eq!(programmatic_flags, cli_flags);
8131 }
8132
8133 #[test]
8134 fn dead_code_registry_filter_flags_are_exposed_by_clap() {
8135 use clap::CommandFactory;
8136
8137 let cli = Cli::command();
8138 let dead_code = cli
8139 .get_subcommands()
8140 .find(|command| command.get_name() == "dead-code")
8141 .expect("dead-code subcommand is registered");
8142 let cli_flags: std::collections::BTreeSet<String> = dead_code
8143 .get_arguments()
8144 .filter_map(|arg| arg.get_long().map(|long| format!("--{long}")))
8145 .collect();
8146
8147 for flag in fallow_types::issue_meta::DEAD_CODE_FILTER_FLAGS.iter() {
8148 assert!(
8149 cli_flags.contains(*flag),
8150 "registry filter flag {flag} is missing from dead-code clap args"
8151 );
8152 }
8153 }
8154
8155 fn help_contains_long_flag(help: &str, long: &str) -> bool {
8156 let flag = format!("--{long}");
8157 help.split(|c: char| c.is_whitespace() || c == ',' || c == '[' || c == ']')
8158 .any(|token| token == flag)
8159 }
8160
8161 fn visit_help(cmd: &mut clap::Command, path: &str, violations: &mut Vec<(String, String)>) {
8162 let help = cmd.render_long_help().to_string();
8163 for line in scan_forbidden(&help) {
8164 violations.push((path.to_owned(), line));
8165 }
8166 let names: Vec<String> = cmd
8167 .get_subcommands()
8168 .map(|sub| sub.get_name().to_owned())
8169 .collect();
8170 for name in names {
8171 if name == "help" {
8172 continue;
8173 }
8174 if let Some(sub) = cmd.find_subcommand_mut(&name) {
8175 let sub_path = format!("{path} {name}");
8176 visit_help(sub, &sub_path, violations);
8177 }
8178 }
8179 }
8180
8181 fn scan_forbidden(s: &str) -> Vec<String> {
8182 let lower = s.to_ascii_lowercase();
8183 let mut out = Vec::new();
8184 for word in ["stub", "placeholder"] {
8185 if let Some(idx) = find_whole_word(&lower, word) {
8186 out.push(extract_line(s, idx));
8187 }
8188 }
8189 if let Some(idx) = lower.find("not yet") {
8190 out.push(extract_line(s, idx));
8191 }
8192 out
8193 }
8194
8195 fn find_whole_word(haystack: &str, word: &str) -> Option<usize> {
8196 let bytes = haystack.as_bytes();
8197 let mut start = 0;
8198 while let Some(rel) = haystack[start..].find(word) {
8199 let abs = start + rel;
8200 let before_ok = abs == 0 || !bytes[abs - 1].is_ascii_alphanumeric();
8201 let after_idx = abs + word.len();
8202 let after_ok = after_idx >= bytes.len() || !bytes[after_idx].is_ascii_alphanumeric();
8203 if before_ok && after_ok {
8204 return Some(abs);
8205 }
8206 start = abs + word.len();
8207 }
8208 None
8209 }
8210
8211 fn extract_line(s: &str, byte_idx: usize) -> String {
8212 let line_start = s[..byte_idx].rfind('\n').map_or(0, |i| i + 1);
8213 let line_end = s[byte_idx..].find('\n').map_or(s.len(), |i| byte_idx + i);
8214 s[line_start..line_end].trim().to_owned()
8215 }
8216
8217 #[test]
8218 fn emit_error_returns_given_exit_code() {
8219 let code = emit_error("test error", 2, fallow_config::OutputFormat::Human);
8220 assert_eq!(code, ExitCode::from(2));
8221 }
8222
8223 fn telemetry_run_for_mode(mode: telemetry::AnalysisMode) -> TelemetryRun {
8224 TelemetryRun {
8225 workflow: telemetry::Workflow::Health,
8226 output: fallow_config::OutputFormat::Json,
8227 quiet: true,
8228 start: std::time::Instant::now(),
8229 context: telemetry::WorkflowContext {
8230 run_scope: telemetry::RunScope::FullProject,
8231 config_shape: telemetry::ConfigShape::Default,
8232 output_destination: telemetry::OutputDestination::Stdout,
8233 analysis_mode: mode,
8234 },
8235 }
8236 }
8237
8238 #[test]
8239 fn fallback_failure_reason_skips_success_and_findings() {
8240 let run = telemetry_run_for_mode(telemetry::AnalysisMode::Static);
8241
8242 assert_eq!(fallback_failure_reason_for(&run, ExitCode::SUCCESS), None);
8243 assert_eq!(fallback_failure_reason_for(&run, ExitCode::from(1)), None);
8244 }
8245
8246 #[test]
8247 fn fallback_failure_reason_classifies_network_auth_and_analysis() {
8248 let static_run = telemetry_run_for_mode(telemetry::AnalysisMode::Static);
8249 let cloud_run = telemetry_run_for_mode(telemetry::AnalysisMode::ProductionCoverage);
8250
8251 assert_eq!(
8252 fallback_failure_reason_for(&static_run, ExitCode::from(api::NETWORK_EXIT_CODE)),
8253 Some(telemetry::FailureReason::Network),
8254 );
8255 assert_eq!(
8256 fallback_failure_reason_for(&static_run, ExitCode::from(12)),
8257 Some(telemetry::FailureReason::Auth),
8258 );
8259 assert_eq!(
8260 fallback_failure_reason_for(&cloud_run, ExitCode::from(3)),
8261 Some(telemetry::FailureReason::Auth),
8262 );
8263 assert_eq!(
8264 fallback_failure_reason_for(&static_run, ExitCode::from(2)),
8265 Some(telemetry::FailureReason::Analysis),
8266 );
8267 }
8268
8269 #[test]
8270 fn bare_coverage_flags_parse_without_subcommand() {
8271 let cli = Cli::try_parse_from([
8272 "fallow",
8273 "--coverage",
8274 "coverage/coverage-final.json",
8275 "--coverage-root",
8276 "/ci/workspace",
8277 ])
8278 .expect("bare combined coverage flags should parse");
8279 assert!(cli.command.is_none());
8280 assert_eq!(
8281 cli.coverage.as_deref(),
8282 Some(std::path::Path::new("coverage/coverage-final.json"))
8283 );
8284 assert_eq!(
8285 cli.coverage_root.as_deref(),
8286 Some(std::path::Path::new("/ci/workspace"))
8287 );
8288 }
8289
8290 #[test]
8291 fn bare_coverage_before_subcommand_is_detectable() {
8292 let cli = Cli::try_parse_from([
8293 "fallow",
8294 "--coverage",
8295 "coverage/coverage-final.json",
8296 "dead-code",
8297 ])
8298 .expect("clap should parse pre-subcommand bare coverage for custom rejection");
8299 assert!(cli.command.is_some());
8300 assert!(cli_has_bare_coverage_input(&cli));
8301 let message = bare_coverage_subcommand_error_message();
8302 assert!(message.contains("bare combined-mode flags"));
8303 assert!(message.contains("fallow health --coverage <coverage-final.json>"));
8304 }
8305
8306 #[test]
8307 fn bare_combined_baseline_before_subcommand_is_detectable() {
8308 for flag in ["--dupes-baseline", "--health-baseline"] {
8309 let cli = Cli::try_parse_from(["fallow", flag, "x.json", "dead-code"])
8310 .expect("clap should parse a pre-subcommand combined baseline");
8311 assert!(cli.command.is_some());
8312 assert_eq!(cli_bare_combined_baseline_flag(&cli), Some(flag));
8313 let message = bare_combined_baseline_subcommand_error_message(flag);
8314 assert!(message.contains(flag));
8315 assert!(message.contains("omit the subcommand"));
8316 }
8317 let bare = Cli::try_parse_from(["fallow", "--dupes-baseline", "x.json"])
8318 .expect("bare combined baseline should parse");
8319 assert!(bare.command.is_none());
8320 }
8321
8322 #[test]
8323 fn subcommand_coverage_flag_keeps_regular_clap_error() {
8324 let Err(err) = Cli::try_parse_from(["fallow", "dead-code", "--coverage"]) else {
8325 panic!("dead-code --coverage should fail to parse");
8326 };
8327 assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
8328 }
8329
8330 #[test]
8331 fn type_aware_flags_parse_for_semantic_analysis() {
8332 let cli = Cli::try_parse_from([
8333 "fallow",
8334 "dead-code",
8335 "--unused-class-members",
8336 "--type-aware",
8337 "--type-aware-project",
8338 "tsconfig.json",
8339 "--type-aware-project",
8340 "packages/web/tsconfig.json",
8341 ])
8342 .expect("type-aware flag should parse");
8343 assert!(cli.type_aware);
8344 assert_eq!(
8345 cli.type_aware_project,
8346 [
8347 PathBuf::from("tsconfig.json"),
8348 PathBuf::from("packages/web/tsconfig.json")
8349 ]
8350 );
8351 let Some(Command::Check {
8352 unused_class_members,
8353 ..
8354 }) = cli.command
8355 else {
8356 panic!("dead-code should parse as the check command");
8357 };
8358 assert!(unused_class_members);
8359 }
8360
8361 #[test]
8362 fn no_type_aware_conflicts_with_type_aware() {
8363 let Err(err) = Cli::try_parse_from(["fallow", "audit", "--type-aware", "--no-type-aware"])
8364 else {
8365 panic!("--no-type-aware must conflict with --type-aware");
8366 };
8367 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
8368 }
8369
8370 #[test]
8371 fn no_type_aware_forces_semantic_analysis_off() {
8372 let cli = Cli::try_parse_from(["fallow", "audit", "--no-type-aware"])
8373 .expect("--no-type-aware should parse on audit");
8374 assert_eq!(cli.type_aware_override(), Some(false));
8375
8376 let cli = Cli::try_parse_from(["fallow", "dead-code", "--type-aware"])
8377 .expect("--type-aware should parse");
8378 assert_eq!(cli.type_aware_override(), Some(true));
8379
8380 let cli = Cli::try_parse_from(["fallow", "dead-code"]).expect("bare command should parse");
8381 assert_eq!(cli.type_aware_override(), None);
8382 }
8383
8384 #[test]
8385 fn type_aware_status_output_hides_host_paths() {
8386 let root = Path::new("/private/work/project");
8387 let output = type_aware_status_output(
8388 root,
8389 fallow_api::TypeAwareStatus {
8390 available: false,
8391 discovery_source: Some("environment-override"),
8392 companion_path: Some(PathBuf::from("/private/tools/fallow-type-aware")),
8393 package_version: None,
8394 protocol_version: 7,
8395 backend_family: None,
8396 backend_version: None,
8397 remediation: Some(
8398 "failed to launch /private/tools/fallow-type-aware from /private/work/project"
8399 .to_string(),
8400 ),
8401 },
8402 );
8403
8404 assert_eq!(
8405 output.schema_version.0,
8406 fallow_output::TYPE_AWARE_STATUS_SCHEMA_VERSION
8407 );
8408 assert_eq!(output.companion_path.as_deref(), Some("fallow-type-aware"));
8409 let remediation = output.remediation.expect("remediation");
8410 assert!(!remediation.contains("/private/"));
8411 assert!(remediation.contains("fallow-type-aware"));
8412 }
8413
8414 #[test]
8415 fn format_parsing_covers_all_variants() {
8416 assert!(matches!(parse_format_arg("json"), Some(Format::Json)));
8417 assert!(matches!(parse_format_arg("JSON"), Some(Format::Json)));
8418 assert!(matches!(parse_format_arg("human"), Some(Format::Human)));
8419 assert!(matches!(parse_format_arg("sarif"), Some(Format::Sarif)));
8420 assert!(matches!(parse_format_arg("compact"), Some(Format::Compact)));
8421 assert!(matches!(
8422 parse_format_arg("markdown"),
8423 Some(Format::Markdown)
8424 ));
8425 assert!(matches!(parse_format_arg("md"), Some(Format::Markdown)));
8426 assert!(matches!(
8427 parse_format_arg("codeclimate"),
8428 Some(Format::CodeClimate)
8429 ));
8430 assert!(matches!(
8431 parse_format_arg("gitlab-codequality"),
8432 Some(Format::CodeClimate)
8433 ));
8434 assert!(matches!(
8435 parse_format_arg("gitlab-code-quality"),
8436 Some(Format::CodeClimate)
8437 ));
8438 assert!(matches!(
8439 parse_format_arg("pr-comment-github"),
8440 Some(Format::PrCommentGithub)
8441 ));
8442 assert!(matches!(
8443 parse_format_arg("pr-comment-gitlab"),
8444 Some(Format::PrCommentGitlab)
8445 ));
8446 assert!(matches!(
8447 parse_format_arg("review-github"),
8448 Some(Format::ReviewGithub)
8449 ));
8450 assert!(matches!(
8451 parse_format_arg("review-gitlab"),
8452 Some(Format::ReviewGitlab)
8453 ));
8454 assert!(matches!(parse_format_arg("badge"), Some(Format::Badge)));
8455 assert!(parse_format_arg("xml").is_none());
8456 assert!(parse_format_arg("").is_none());
8457 }
8458
8459 #[test]
8460 fn quiet_parsing_logic() {
8461 let parse = |s: &str| -> bool { s == "1" || s.eq_ignore_ascii_case("true") };
8462 assert!(parse("1"));
8463 assert!(parse("true"));
8464 assert!(parse("TRUE"));
8465 assert!(parse("True"));
8466 assert!(!parse("0"));
8467 assert!(!parse("false"));
8468 assert!(!parse("yes"));
8469 }
8470
8471 #[test]
8472 fn tracing_filter_defaults_to_warn_without_env() {
8473 assert_eq!(build_tracing_filter(None).to_string(), "warn");
8474 }
8475
8476 #[test]
8477 fn tracing_filter_respects_explicit_env_directives() {
8478 assert_eq!(build_tracing_filter(Some("info")).to_string(), "info");
8479 }
8480
8481 #[test]
8482 fn tracing_filter_treats_empty_env_as_off() {
8483 assert_eq!(build_tracing_filter(Some("")).to_string(), "off");
8484 assert_eq!(build_tracing_filter(Some(" ")).to_string(), "off");
8485 }
8486}