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)]
404 baseline: Option<PathBuf>,
405
406 #[arg(
421 hide_short_help = true,
422 long = "baseline-mode",
423 value_enum,
424 global = true
425 )]
426 baseline_mode: Option<BaselineModeArg>,
427
428 #[arg(long, global = true, value_name = "RUN_ID", hide = true)]
434 parent_run: Option<String>,
435
436 #[arg(hide_short_help = true, long, global = true)]
443 save_baseline: Option<PathBuf>,
444
445 #[arg(long, global = true)]
448 production: bool,
449
450 #[arg(
454 hide_short_help = true,
455 long = "no-production",
456 global = true,
457 conflicts_with = "production"
458 )]
459 no_production: bool,
460
461 #[arg(hide_short_help = true, long = "production-dead-code")]
463 production_dead_code: bool,
464
465 #[arg(hide_short_help = true, long = "production-health")]
467 production_health: bool,
468
469 #[arg(hide_short_help = true, long = "production-dupes")]
471 production_dupes: bool,
472
473 #[arg(short, long, global = true, value_delimiter = ',')]
477 workspace: Option<Vec<String>>,
478
479 #[arg(long, global = true, value_name = "REF")]
482 changed_workspaces: Option<String>,
483
484 #[arg(hide_short_help = true, long, global = true)]
486 group_by: Option<GroupBy>,
487
488 #[arg(
493 hide_short_help = true,
494 long,
495 global = true,
496 value_delimiter = ',',
497 value_name = "KEY",
498 requires = "group_by"
499 )]
500 group: Option<Vec<String>>,
501
502 #[arg(hide_short_help = true, long, global = true)]
504 performance: bool,
505
506 #[arg(hide_short_help = true, long, global = true)]
508 explain: bool,
509
510 #[arg(hide_short_help = true, long, global = true)]
513 explain_skipped: bool,
514
515 #[arg(hide_short_help = true, long, global = true)]
517 summary: bool,
518
519 #[arg(long, global = true)]
521 ci: bool,
522
523 #[arg(hide_short_help = true, long, global = true)]
525 fail_on_issues: bool,
526
527 #[arg(hide_short_help = true, long, global = true, value_name = "PATH")]
532 sarif_file: Option<PathBuf>,
533
534 #[arg(short = 'o', long, global = true, value_name = "PATH")]
540 output_file: Option<PathBuf>,
541
542 #[arg(
552 hide_short_help = true,
553 long = "report-path-prefix",
554 visible_alias = "annotations-path-prefix",
555 global = true,
556 value_name = "PREFIX"
557 )]
558 report_path_prefix: Option<String>,
559
560 #[arg(hide_short_help = true, long, global = true)]
566 fail_on_regression: bool,
567
568 #[arg(hide_short_help = true, long, global = true)]
582 fail_on_stale_baseline: bool,
583
584 #[arg(hide_short_help = true, long, global = true)]
599 fail_on_baseline_growth: bool,
600
601 #[arg(hide_short_help = true, long, global = true, value_name = "REF")]
606 baseline_base: Option<String>,
607
608 #[arg(hide_short_help = true, long, global = true)]
618 fail_on_parse_error: bool,
619
620 #[arg(
626 hide_short_help = true,
627 long,
628 global = true,
629 value_name = "TOLERANCE",
630 default_value = "0"
631 )]
632 tolerance: String,
633
634 #[arg(hide_short_help = true, long, global = true, value_name = "PATH")]
636 regression_baseline: Option<PathBuf>,
637
638 #[expect(
643 clippy::option_option,
644 reason = "clap pattern: None=not passed, Some(None)=flag only (write to config), Some(Some(path))=write to file"
645 )]
646 #[arg(hide_short_help = true, long, global = true, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
647 save_regression_baseline: Option<Option<String>>,
648
649 #[arg(long, value_delimiter = ',')]
651 only: Vec<AnalysisKind>,
652
653 #[arg(long, value_delimiter = ',')]
655 skip: Vec<AnalysisKind>,
656
657 #[arg(hide_short_help = true, long = "dupes-mode", global = true)]
659 dupes_mode: Option<DupesMode>,
660
661 #[arg(hide_short_help = true, long = "dupes-near", global = true)]
663 dupes_near: bool,
664
665 #[arg(hide_short_help = true, long = "dupes-threshold", global = true)]
667 dupes_threshold: Option<f64>,
668
669 #[arg(hide_short_help = true, long = "dupes-min-tokens", global = true)]
671 dupes_min_tokens: Option<usize>,
672
673 #[arg(hide_short_help = true, long = "dupes-min-lines", global = true)]
675 dupes_min_lines: Option<usize>,
676
677 #[arg(hide_short_help = true, long = "dupes-min-occurrences", global = true, value_parser = parse_min_occurrences)]
679 dupes_min_occurrences: Option<usize>,
680
681 #[arg(hide_short_help = true, long = "dupes-skip-local", global = true)]
683 dupes_skip_local: bool,
684
685 #[arg(hide_short_help = true, long = "dupes-cross-language", global = true)]
687 dupes_cross_language: bool,
688
689 #[arg(hide_short_help = true, long = "dupes-ignore-imports", global = true)]
692 dupes_ignore_imports: bool,
693
694 #[arg(
697 hide_short_help = true,
698 long = "dupes-no-ignore-imports",
699 global = true,
700 conflicts_with = "dupes_ignore_imports"
701 )]
702 dupes_no_ignore_imports: bool,
703
704 #[arg(hide_short_help = true, long = "dupes-ignore-symlinks", global = true)]
707 dupes_ignore_symlinks: bool,
708
709 #[arg(
712 hide_short_help = true,
713 long = "dupes-no-ignore-symlinks",
714 global = true,
715 conflicts_with = "dupes_ignore_symlinks"
716 )]
717 dupes_no_ignore_symlinks: bool,
718
719 #[arg(hide_short_help = true, long)]
721 score: bool,
722
723 #[arg(hide_short_help = true, long)]
725 trend: bool,
726
727 #[arg(hide_short_help = true, long, value_name = "PATH")]
730 trend_from: Option<PathBuf>,
731
732 #[expect(
736 clippy::option_option,
737 reason = "clap pattern: None=not passed, Some(None)=default path, Some(Some(path))=custom path"
738 )]
739 #[arg(hide_short_help = true, long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
740 save_snapshot: Option<Option<String>>,
741
742 #[arg(hide_short_help = true, long, value_name = "PATH")]
745 coverage: Option<PathBuf>,
746
747 #[arg(hide_short_help = true, long = "coverage-root", value_name = "PATH")]
750 coverage_root: Option<PathBuf>,
751
752 #[arg(hide_short_help = true, long = "dupes-baseline", value_name = "PATH")]
755 dupes_baseline: Option<PathBuf>,
756
757 #[arg(hide_short_help = true, long = "health-baseline", value_name = "PATH")]
760 health_baseline: Option<PathBuf>,
761
762 #[arg(hide_short_help = true, long, global = true)]
764 include_entry_exports: bool,
765
766 #[arg(hide_short_help = true, long, global = true)]
770 show_cascade: bool,
771
772 #[arg(hide_short_help = true, long, global = true)]
775 type_aware: bool,
776
777 #[arg(
780 hide_short_help = true,
781 long,
782 global = true,
783 conflicts_with = "type_aware"
784 )]
785 no_type_aware: bool,
786
787 #[arg(hide_short_help = true, long, global = true, value_name = "PATH", action = clap::ArgAction::Append)]
789 type_aware_project: Vec<PathBuf>,
790
791 #[arg(hide_short_help = true, long, global = true, value_enum)]
793 type_aware_require: Option<TypeAwareRequireArg>,
794}
795
796impl Cli {
797 const fn type_aware_override(&self) -> Option<bool> {
801 if self.no_type_aware {
802 Some(false)
803 } else if self.type_aware {
804 Some(true)
805 } else {
806 None
807 }
808 }
809
810 const fn dupes_overrides(&self) -> DupesOverrides {
812 DupesOverrides {
813 mode: self.dupes_mode,
814 near: self.dupes_near,
815 threshold: self.dupes_threshold,
816 min_tokens: self.dupes_min_tokens,
817 min_lines: self.dupes_min_lines,
818 min_occurrences: self.dupes_min_occurrences,
819 skip_local: self.dupes_skip_local,
820 ignore_symlinks: resolve_negatable_flag(
821 self.dupes_ignore_symlinks,
822 self.dupes_no_ignore_symlinks,
823 ),
824 cross_language: self.dupes_cross_language,
825 ignore_imports: resolve_negatable_flag(
826 self.dupes_ignore_imports,
827 self.dupes_no_ignore_imports,
828 ),
829 }
830 }
831}
832
833#[derive(Clone, Copy, Subcommand)]
834enum TypeAwareCli {
835 Status,
837}
838
839#[derive(Subcommand)]
840enum Command {
841 #[command(name = "dead-code", alias = "check")]
843 Check {
844 #[arg(long)]
846 unused_files: bool,
847
848 #[arg(long)]
850 unused_exports: bool,
851
852 #[arg(long)]
854 unused_deps: bool,
855
856 #[arg(long)]
858 unused_types: bool,
859
860 #[arg(long)]
862 private_type_leaks: bool,
863
864 #[arg(long)]
866 deprecated_exports_in_use: bool,
867
868 #[arg(long)]
870 unused_enum_members: bool,
871
872 #[arg(long)]
874 unused_class_members: bool,
875
876 #[arg(long)]
878 unused_store_members: bool,
879
880 #[arg(long)]
882 unprovided_injects: bool,
883
884 #[arg(long)]
886 unrendered_components: bool,
887
888 #[arg(long)]
890 unused_component_props: bool,
891
892 #[arg(long)]
894 absent_component_props: bool,
895
896 #[arg(long)]
898 unused_component_emits: bool,
899
900 #[arg(long)]
902 unused_component_inputs: bool,
903
904 #[arg(long)]
906 unused_component_outputs: bool,
907
908 #[arg(long)]
910 unused_svelte_events: bool,
911
912 #[arg(long)]
914 unused_server_actions: bool,
915
916 #[arg(long)]
918 unused_load_data_keys: bool,
919
920 #[arg(long)]
922 unresolved_imports: bool,
923
924 #[arg(long)]
926 unlisted_deps: bool,
927
928 #[arg(long)]
930 duplicate_exports: bool,
931
932 #[arg(long)]
935 circular_deps: bool,
936
937 #[arg(long)]
940 re_export_cycles: bool,
941
942 #[arg(long)]
945 package_cycles: bool,
946
947 #[arg(long)]
950 boundary_violations: bool,
951
952 #[arg(long)]
955 policy_violations: bool,
956
957 #[arg(long)]
959 stale_suppressions: bool,
960
961 #[arg(long)]
963 unused_catalog_entries: bool,
964
965 #[arg(long)]
967 empty_catalog_groups: bool,
968
969 #[arg(long)]
971 unresolved_catalog_references: bool,
972
973 #[arg(long)]
975 unused_dependency_overrides: bool,
976
977 #[arg(long)]
979 misconfigured_dependency_overrides: bool,
980
981 #[arg(long)]
983 include_dupes: bool,
984
985 #[arg(long, value_name = "FILE:EXPORT")]
987 trace: Option<String>,
988
989 #[arg(long, value_name = "PATH")]
991 trace_file: Option<String>,
992
993 #[arg(long, value_name = "PACKAGE")]
995 trace_dependency: Option<String>,
996
997 #[arg(long, value_name = "PATH")]
1001 impact_closure: Option<String>,
1002
1003 #[arg(long, value_name = "FILE:EXPORT")]
1005 symbol_impact: Option<String>,
1006
1007 #[arg(long)]
1012 top: Option<usize>,
1013
1014 #[arg(long, value_name = "PATH")]
1018 file: Vec<std::path::PathBuf>,
1019
1020 #[arg(long = "finding-id", value_name = "ID", value_delimiter = ',')]
1025 finding_id: Vec<String>,
1026
1027 #[arg(value_name = "PATH")]
1030 path: Option<std::path::PathBuf>,
1031 },
1032
1033 Architecture {
1038 #[arg(long)]
1041 cycles: bool,
1042
1043 #[arg(long)]
1045 boundaries: bool,
1046
1047 #[arg(long)]
1049 policy: bool,
1050
1051 #[arg(long, value_name = "PATH")]
1055 file: Vec<std::path::PathBuf>,
1056
1057 #[arg(long = "finding-id", value_name = "ID", value_delimiter = ',')]
1060 finding_id: Vec<String>,
1061
1062 #[arg(value_name = "PATH")]
1065 path: Option<std::path::PathBuf>,
1066 },
1067
1068 Watch {
1070 #[arg(long)]
1072 no_clear: bool,
1073 },
1074
1075 TypeAware {
1077 #[command(subcommand)]
1078 subcommand: TypeAwareCli,
1079 },
1080
1081 #[command(override_help = doctor::HELP)]
1088 Doctor,
1089
1090 SimilarCode {
1096 #[command(subcommand)]
1097 subcommand: Option<similar_code_cli::SimilarCodeSubcommand>,
1098 #[arg(long, value_name = "0..1")]
1100 threshold: Option<f64>,
1101 #[arg(long, value_name = "N")]
1103 min_lines: Option<usize>,
1104 #[arg(long, value_name = "N")]
1106 top: Option<usize>,
1107 #[arg(long, value_name = "PATH")]
1109 file: Vec<PathBuf>,
1110
1111 #[arg(value_name = "PATH")]
1114 path: Option<PathBuf>,
1115 },
1116
1117 Inspect {
1119 #[arg(
1121 long,
1122 value_name = "PATH",
1123 conflicts_with = "symbol",
1124 required_unless_present = "symbol"
1125 )]
1126 file: Option<String>,
1127
1128 #[arg(long, value_name = "FILE:EXPORT", conflicts_with = "file")]
1130 symbol: Option<String>,
1131
1132 #[arg(long)]
1137 symbol_chain: bool,
1138
1139 #[arg(long)]
1142 churn: bool,
1143 },
1144
1145 Trace {
1160 #[arg(
1163 value_name = "FILE:SYMBOL",
1164 required_unless_present_any = ["path", "dependency"]
1165 )]
1166 symbol: Option<String>,
1167
1168 #[arg(
1175 long,
1176 value_name = "PACKAGE",
1177 conflicts_with_all = ["symbol", "path", "callees"]
1178 )]
1179 dependency: Option<String>,
1180
1181 #[arg(
1184 long,
1185 value_name = "NAME",
1186 value_delimiter = ',',
1187 requires = "dependency"
1188 )]
1189 specifier: Vec<String>,
1190
1191 #[arg(long, requires = "dependency")]
1193 sites: bool,
1194
1195 #[arg(
1198 long,
1199 value_name = "N",
1200 requires = "dependency",
1201 value_parser = clap::value_parser!(u16).range(1..=500)
1202 )]
1203 limit: Option<u16>,
1204
1205 #[arg(long, value_name = "TOKEN", requires = "dependency")]
1208 cursor: Option<String>,
1209
1210 #[arg(
1214 long,
1215 num_args = 2,
1216 value_names = ["FROM", "TO"],
1217 conflicts_with_all = ["symbol", "callers", "callees", "depth"]
1218 )]
1219 path: Vec<String>,
1220
1221 #[arg(long, requires = "path")]
1227 eager_only: bool,
1228
1229 #[arg(long)]
1232 callers: bool,
1233
1234 #[arg(long)]
1237 callees: bool,
1238
1239 #[arg(long, value_name = "N")]
1242 depth: Option<u32>,
1243 },
1244
1245 #[command(name = "trace-error")]
1264 TraceError {
1265 #[arg(value_name = "FILE")]
1269 trace_file: Option<String>,
1270 },
1271
1272 Fix {
1287 #[arg(long)]
1289 dry_run: bool,
1290
1291 #[arg(long, alias = "force")]
1293 yes: bool,
1294
1295 #[arg(long)]
1302 no_create_config: bool,
1303
1304 #[arg(value_name = "PATH")]
1308 path: Option<PathBuf>,
1309 },
1310
1311 Init {
1320 #[arg(long)]
1322 toml: bool,
1323
1324 #[arg(long, conflicts_with_all = ["toml", "hooks", "branch"])]
1326 agents: bool,
1327
1328 #[arg(long)]
1332 hooks: bool,
1333
1334 #[arg(long, requires = "hooks")]
1336 branch: Option<String>,
1337
1338 #[arg(long, conflicts_with_all = ["toml", "agents", "hooks", "branch"])]
1342 decline: bool,
1343 },
1344
1345 Hooks {
1352 #[command(subcommand)]
1353 subcommand: HooksCli,
1354 },
1355
1356 Agent {
1362 #[command(subcommand)]
1363 subcommand: AgentCli,
1364 },
1365
1366 Ci {
1368 #[command(subcommand)]
1369 subcommand: CiCli,
1370 },
1371
1372 ConfigSchema,
1374
1375 PluginSchema,
1377
1378 PluginCheck,
1380
1381 RulePackSchema,
1383
1384 RulePack {
1386 #[command(subcommand)]
1387 subcommand: RulePackCli,
1388 },
1389
1390 Guard {
1392 #[arg(required = true, num_args = 1..)]
1394 files: Vec<String>,
1395 },
1396
1397 Config {
1415 #[arg(long)]
1417 path: bool,
1418 },
1419
1420 Recommend,
1428
1429 List {
1432 #[arg(long)]
1434 entry_points: bool,
1435
1436 #[arg(long)]
1438 files: bool,
1439
1440 #[arg(long)]
1442 plugins: bool,
1443
1444 #[arg(long)]
1446 boundaries: bool,
1447
1448 #[arg(long)]
1452 workspaces: bool,
1453
1454 #[arg(long)]
1464 entry_weight: bool,
1465
1466 #[arg(value_name = "PATH")]
1469 path: Option<PathBuf>,
1470 },
1471
1472 Workspaces,
1478
1479 Dupes {
1481 #[arg(long)]
1484 mode: Option<DupesMode>,
1485
1486 #[arg(long)]
1488 near: bool,
1489
1490 #[arg(long)]
1493 min_tokens: Option<usize>,
1494
1495 #[arg(long)]
1498 min_lines: Option<usize>,
1499
1500 #[arg(long, value_parser = parse_min_occurrences)]
1505 min_occurrences: Option<usize>,
1506
1507 #[arg(long)]
1510 threshold: Option<f64>,
1511
1512 #[arg(long)]
1514 skip_local: bool,
1515
1516 #[arg(long)]
1518 cross_language: bool,
1519
1520 #[arg(long)]
1524 ignore_imports: bool,
1525
1526 #[arg(long, conflicts_with = "ignore_imports")]
1529 no_ignore_imports: bool,
1530
1531 #[arg(long)]
1536 ignore_symlinks: bool,
1537
1538 #[arg(long, conflicts_with = "ignore_symlinks")]
1541 no_ignore_symlinks: bool,
1542
1543 #[arg(long)]
1553 top: Option<usize>,
1554
1555 #[arg(long)]
1559 no_fragments: bool,
1560
1561 #[arg(long, value_name = "FILE:LINE")]
1563 trace: Option<String>,
1564
1565 #[arg(value_name = "PATH")]
1568 path: Option<PathBuf>,
1569 },
1570
1571 Health {
1577 #[arg(long)]
1579 max_cyclomatic: Option<u16>,
1580
1581 #[arg(long)]
1583 max_cognitive: Option<u16>,
1584
1585 #[arg(long)]
1589 max_crap: Option<f64>,
1590
1591 #[arg(long)]
1593 top: Option<usize>,
1594
1595 #[arg(long, default_value = "cyclomatic")]
1597 sort: SortBy,
1598
1599 #[arg(long)]
1602 complexity: bool,
1603
1604 #[arg(long)]
1611 complexity_breakdown: bool,
1612
1613 #[arg(long)]
1618 file_scores: bool,
1619
1620 #[arg(long)]
1623 coverage_gaps: bool,
1624
1625 #[arg(long)]
1628 hotspots: bool,
1629
1630 #[arg(long)]
1634 ownership: bool,
1635
1636 #[arg(long, value_name = "MODE", value_enum)]
1641 ownership_emails: Option<EmailModeArg>,
1642
1643 #[arg(long)]
1646 targets: bool,
1647
1648 #[arg(long)]
1651 type_coupling: bool,
1652
1653 #[arg(long)]
1658 css: bool,
1659
1660 #[arg(long, value_enum)]
1663 effort: Option<EffortFilter>,
1664
1665 #[arg(long)]
1668 score: bool,
1669
1670 #[arg(long, value_name = "N")]
1679 min_score: Option<f64>,
1680
1681 #[arg(long, value_name = "LEVEL", value_enum)]
1687 min_severity: Option<HealthSeverityCli>,
1688
1689 #[arg(long)]
1693 report_only: bool,
1694
1695 #[arg(long, value_name = "DURATION")]
1698 since: Option<String>,
1699
1700 #[arg(long, value_name = "N")]
1702 min_commits: Option<u32>,
1703
1704 #[expect(
1709 clippy::option_option,
1710 reason = "clap pattern: None=not passed, Some(None)=flag only, Some(Some(path))=with value"
1711 )]
1712 #[arg(long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
1713 save_snapshot: Option<Option<String>>,
1714
1715 #[arg(long)]
1719 trend: bool,
1720
1721 #[arg(long, value_name = "PATH")]
1726 trend_from: Option<PathBuf>,
1727
1728 #[arg(long, value_name = "PATH")]
1741 coverage: Option<PathBuf>,
1742
1743 #[arg(long, value_name = "PATH")]
1749 coverage_root: Option<PathBuf>,
1750
1751 #[arg(long, value_name = "PATH")]
1757 runtime_coverage: Option<PathBuf>,
1758
1759 #[arg(long, default_value_t = 100)]
1761 min_invocations_hot: u64,
1762
1763 #[arg(long, value_name = "N")]
1769 min_observation_volume: Option<u32>,
1770
1771 #[arg(long, value_name = "RATIO")]
1776 low_traffic_threshold: Option<f64>,
1777
1778 #[arg(value_name = "PATH")]
1781 path: Option<PathBuf>,
1782 },
1783
1784 Flags {
1791 #[arg(long)]
1793 top: Option<usize>,
1794
1795 #[arg(long)]
1798 retirement: bool,
1799
1800 #[arg(long = "reason", value_name = "CODE", requires = "retirement")]
1802 reasons: Vec<flags::RetirementReasonArg>,
1803
1804 #[arg(
1806 long,
1807 value_name = "KEY",
1808 requires = "retirement",
1809 default_value = "age"
1810 )]
1811 sort: flags::RetirementSortArg,
1812
1813 #[arg(
1816 long,
1817 value_name = "MODE",
1818 requires = "retirement",
1819 default_value = "blame"
1820 )]
1821 flag_age: flags::FlagAgeArg,
1822
1823 #[arg(long, value_name = "DAYS", requires = "retirement")]
1825 min_age: Option<u64>,
1826
1827 #[arg(long, value_name = "FILE", requires = "retirement")]
1831 flag_state: Option<std::path::PathBuf>,
1832
1833 #[arg(long, value_name = "DAYS", requires = "retirement")]
1836 max_flag_age: Option<u64>,
1837 },
1838
1839 Suppressions {
1849 #[arg(long, value_name = "PATH")]
1851 file: Vec<std::path::PathBuf>,
1852 },
1853
1854 Explain {
1860 #[arg(required = true, num_args = 1.., value_name = "ISSUE_TYPE")]
1862 issue_type: Vec<String>,
1863 },
1864
1865 #[command(visible_alias = "review")]
1890 Audit {
1891 #[arg(long = "production-dead-code")]
1893 production_dead_code: bool,
1894
1895 #[arg(long = "production-health")]
1897 production_health: bool,
1898
1899 #[arg(long = "production-dupes")]
1901 production_dupes: bool,
1902
1903 #[arg(long)]
1906 dead_code_baseline: Option<PathBuf>,
1907
1908 #[arg(long)]
1911 health_baseline: Option<PathBuf>,
1912
1913 #[arg(long)]
1916 dupes_baseline: Option<PathBuf>,
1917
1918 #[arg(long)]
1922 max_crap: Option<f64>,
1923
1924 #[arg(long, value_name = "PATH")]
1929 coverage: Option<PathBuf>,
1930
1931 #[arg(long, value_name = "PATH")]
1935 coverage_root: Option<PathBuf>,
1936
1937 #[arg(long = "no-css")]
1939 no_css: bool,
1940
1941 #[arg(long)]
1945 css_deep: bool,
1946
1947 #[arg(long = "no-css-deep")]
1949 no_css_deep: bool,
1950
1951 #[arg(long, value_enum)]
1957 gate: Option<AuditGateArg>,
1958
1959 #[arg(long, value_name = "PATH")]
1968 runtime_coverage: Option<PathBuf>,
1969
1970 #[arg(long, default_value_t = 100)]
1973 min_invocations_hot: u64,
1974
1975 #[arg(long, value_name = "MARKER", hide = true)]
1980 gate_marker: Option<String>,
1981
1982 #[arg(long)]
1988 brief: bool,
1989
1990 #[arg(
1995 long,
1996 value_name = "N",
1997 default_value_t = audit_decision_surface::DEFAULT_DECISION_CAP
1998 )]
1999 max_decisions: usize,
2000
2001 #[arg(long, conflicts_with_all = ["walkthrough_file", "walkthrough"])]
2009 walkthrough_guide: bool,
2010
2011 #[arg(long, value_name = "PATH")]
2021 walkthrough_file: Option<PathBuf>,
2022
2023 #[arg(long, conflicts_with_all = ["walkthrough_guide", "walkthrough_file"])]
2029 walkthrough: bool,
2030
2031 #[arg(long, value_name = "PATH")]
2037 mark_viewed: Vec<PathBuf>,
2038
2039 #[arg(long)]
2043 show_cleared: bool,
2044
2045 #[arg(long)]
2051 show_deprioritized: bool,
2052
2053 #[arg(value_name = "PATH")]
2056 path: Option<PathBuf>,
2057 },
2058
2059 AuditCache {
2061 #[command(subcommand)]
2062 subcommand: AuditCacheCli,
2063 },
2064
2065 Baselines {
2067 #[command(subcommand)]
2068 subcommand: BaselinesCli,
2069 },
2070
2071 DecisionSurface {
2086 #[arg(
2089 long,
2090 value_name = "N",
2091 default_value_t = audit_decision_surface::DEFAULT_DECISION_CAP
2092 )]
2093 max_decisions: usize,
2094 },
2095
2096 Impact {
2106 #[command(subcommand)]
2107 subcommand: Option<ImpactCli>,
2108 #[arg(long)]
2112 all: bool,
2113 #[arg(long, value_enum, default_value_t = ImpactSortCli::Recent)]
2115 sort: ImpactSortCli,
2116 #[arg(long)]
2119 limit: Option<usize>,
2120 },
2121
2122 Security {
2153 #[command(subcommand)]
2154 subcommand: Option<SecuritySubcommand>,
2155 #[arg(long, value_name = "PATH")]
2162 runtime_coverage: Option<PathBuf>,
2163 #[arg(long, default_value_t = 100)]
2166 min_invocations_hot: u64,
2167 #[arg(long, value_name = "PATH")]
2171 file: Vec<std::path::PathBuf>,
2172 #[arg(long, value_name = "MODE")]
2178 gate: Option<security::SecurityGateArg>,
2179 #[arg(long)]
2181 surface: bool,
2182
2183 #[arg(value_name = "PATH")]
2186 path: Option<PathBuf>,
2187 },
2188
2189 Report {
2194 #[arg(long, value_name = "PATH")]
2198 from: PathBuf,
2199 },
2200 Schema,
2202
2203 CiTemplate {
2210 #[command(subcommand)]
2211 subcommand: CiTemplateCli,
2212 },
2213
2214 Migrate {
2216 #[arg(long, conflicts_with = "jsonc")]
2218 toml: bool,
2219
2220 #[arg(long)]
2228 jsonc: bool,
2229
2230 #[arg(long)]
2232 dry_run: bool,
2233
2234 #[arg(long, value_name = "PATH")]
2236 from: Option<PathBuf>,
2237 },
2238
2239 License {
2246 #[command(subcommand)]
2247 subcommand: LicenseCli,
2248 },
2249
2250 Telemetry {
2258 #[command(subcommand)]
2259 subcommand: TelemetryCli,
2260 },
2261
2262 Coverage {
2267 #[command(subcommand)]
2268 subcommand: CoverageCli,
2269 },
2270
2271 SetupHooks {
2284 #[arg(long, value_enum)]
2286 agent: Option<setup_hooks::HookAgentArg>,
2287
2288 #[arg(long)]
2290 dry_run: bool,
2291
2292 #[arg(long)]
2295 force: bool,
2296
2297 #[arg(long)]
2299 user: bool,
2300
2301 #[arg(long)]
2303 gitignore_claude: bool,
2304
2305 #[arg(long)]
2309 uninstall: bool,
2310 },
2311
2312 Viz {
2314 #[arg(long = "out", value_name = "PATH")]
2316 output: Option<PathBuf>,
2317
2318 #[arg(long)]
2320 no_open: bool,
2321
2322 #[arg(long = "viz-format", default_value = "html")]
2324 viz_format: viz::VizFormat,
2325 },
2326}
2327
2328#[derive(Subcommand)]
2329enum SecuritySubcommand {
2330 Survivors {
2332 #[arg(long, value_name = "PATH")]
2334 candidates: PathBuf,
2335 #[arg(long, value_name = "PATH")]
2337 verdicts: PathBuf,
2338 #[arg(long)]
2340 require_verdict_for_each_candidate: bool,
2341 },
2342 #[command(name = "blind-spots")]
2344 BlindSpots {
2345 #[arg(long, value_name = "PATH")]
2347 file: Vec<PathBuf>,
2348 },
2349}
2350
2351#[derive(clap::Subcommand)]
2352enum BaselinesCli {
2353 Prune {
2367 #[arg(long)]
2369 check: bool,
2370
2371 #[arg(long, value_name = "PATH")]
2373 dead_code_baseline: Option<PathBuf>,
2374
2375 #[arg(long, value_name = "PATH")]
2377 health_baseline: Option<PathBuf>,
2378
2379 #[arg(long, value_name = "PATH")]
2381 dupes_baseline: Option<PathBuf>,
2382
2383 #[arg(long, value_name = "PATH")]
2386 coverage: Option<PathBuf>,
2387
2388 #[arg(long, value_name = "PATH")]
2390 coverage_root: Option<PathBuf>,
2391 },
2392}
2393
2394#[derive(clap::Subcommand)]
2395enum AuditCacheCli {
2396 Remove {
2402 #[arg(long)]
2404 dry_run: bool,
2405
2406 #[arg(long, alias = "force")]
2408 yes: bool,
2409 },
2410
2411 Prune {
2424 #[arg(long)]
2426 dry_run: bool,
2427
2428 #[arg(long, value_name = "N")]
2435 max_age_days: Option<u32>,
2436 },
2437}
2438
2439#[derive(clap::Subcommand)]
2440enum LicenseCli {
2441 Activate {
2446 #[arg(value_name = "JWT")]
2448 jwt: Option<String>,
2449
2450 #[arg(long, value_name = "PATH")]
2452 from_file: Option<PathBuf>,
2453
2454 #[arg(long, conflicts_with_all = ["jwt", "from_file"])]
2456 stdin: bool,
2457
2458 #[arg(long, requires = "email")]
2465 trial: bool,
2466
2467 #[arg(long, value_name = "ADDR")]
2469 email: Option<String>,
2470 },
2471 Status,
2473 Refresh {
2479 #[arg(long, value_name = "KEY")]
2489 api_key: Option<String>,
2490 },
2491 Deactivate,
2493}
2494
2495#[derive(Clone, Copy, clap::Subcommand)]
2496enum TelemetryCli {
2497 Status,
2499 Enable,
2501 Disable,
2503 Inspect {
2505 #[arg(long)]
2507 example: bool,
2508 },
2509}
2510
2511#[derive(clap::Subcommand)]
2512enum CiTemplateCli {
2513 Gitlab {
2515 #[arg(long, value_name = "DIR", num_args = 0..=1, default_missing_value = ".")]
2519 vendor: Option<PathBuf>,
2520
2521 #[arg(long)]
2523 force: bool,
2524 },
2525}
2526
2527#[derive(clap::Subcommand)]
2528enum CoverageCli {
2529 Setup {
2531 #[arg(short = 'y', long)]
2533 yes: bool,
2534
2535 #[arg(long)]
2537 non_interactive: bool,
2538
2539 #[arg(long)]
2541 json: bool,
2542 },
2543 Analyze {
2549 #[arg(long, value_name = "PATH", conflicts_with = "cloud")]
2553 runtime_coverage: Option<PathBuf>,
2554
2555 #[arg(long, visible_alias = "runtime-coverage-cloud")]
2557 cloud: bool,
2558
2559 #[arg(long, value_name = "KEY")]
2561 api_key: Option<String>,
2562
2563 #[arg(long, value_name = "URL")]
2565 api_endpoint: Option<String>,
2566
2567 #[arg(long, value_name = "OWNER/REPO")]
2573 repo: Option<String>,
2574
2575 #[arg(long, value_name = "ID")]
2577 project_id: Option<String>,
2578
2579 #[arg(long, value_name = "DAYS", default_value_t = 30)]
2581 coverage_period: u16,
2582
2583 #[arg(long, value_name = "ENV")]
2585 environment: Option<String>,
2586
2587 #[arg(long, value_name = "SHA")]
2589 commit_sha: Option<String>,
2590
2591 #[arg(long)]
2593 production: bool,
2594
2595 #[arg(long, default_value_t = 100)]
2597 min_invocations_hot: u64,
2598
2599 #[arg(long, value_name = "N")]
2601 min_observation_volume: Option<u32>,
2602
2603 #[arg(long, value_name = "RATIO")]
2605 low_traffic_threshold: Option<f64>,
2606
2607 #[arg(long)]
2609 top: Option<usize>,
2610
2611 #[arg(long)]
2613 blast_radius: bool,
2614
2615 #[arg(long)]
2617 importance: bool,
2618
2619 #[arg(long)]
2621 debug_unmatched: bool,
2622 },
2623 UploadInventory {
2634 #[arg(long, value_name = "KEY")]
2643 api_key: Option<String>,
2644
2645 #[arg(long, value_name = "URL")]
2650 api_endpoint: Option<String>,
2651
2652 #[arg(long, value_name = "PROJECT_ID")]
2657 project_id: Option<String>,
2658
2659 #[arg(long, value_name = "SHA")]
2664 git_sha: Option<String>,
2665
2666 #[arg(long)]
2672 allow_dirty: bool,
2673
2674 #[arg(long, value_name = "GLOB", num_args = 0..)]
2678 exclude_paths: Vec<String>,
2679
2680 #[arg(long, value_name = "PREFIX")]
2693 path_prefix: Option<String>,
2694
2695 #[arg(long)]
2697 dry_run: bool,
2698
2699 #[arg(long)]
2705 with_callers: bool,
2706
2707 #[arg(long)]
2711 ignore_upload_errors: bool,
2712 },
2713 UploadSourceMaps {
2726 #[arg(long, value_name = "PATH", default_value = "dist")]
2728 dir: PathBuf,
2729
2730 #[arg(long, value_name = "GLOB", default_value = "**/*.map")]
2732 include: String,
2733
2734 #[arg(long, value_name = "GLOB", default_value = "**/node_modules/**")]
2738 exclude: Vec<String>,
2739
2740 #[arg(long, value_name = "NAME")]
2744 repo: Option<String>,
2745
2746 #[arg(long, value_name = "SHA")]
2751 git_sha: Option<String>,
2752
2753 #[arg(long, value_name = "URL")]
2755 endpoint: Option<String>,
2756
2757 #[arg(long, value_name = "BOOL", default_value_t = true, action = clap::ArgAction::Set)]
2764 strip_path: bool,
2765
2766 #[arg(long)]
2768 dry_run: bool,
2769
2770 #[arg(long, value_name = "N", default_value_t = 4)]
2772 concurrency: usize,
2773
2774 #[arg(long)]
2776 fail_fast: bool,
2777 },
2778 UploadStaticFindings {
2785 #[arg(long, value_name = "KEY")]
2795 api_key: Option<String>,
2796
2797 #[arg(long, value_name = "URL")]
2802 api_endpoint: Option<String>,
2803
2804 #[arg(long, value_name = "PROJECT_ID")]
2809 project_id: Option<String>,
2810
2811 #[arg(long, value_name = "SHA")]
2816 git_sha: Option<String>,
2817
2818 #[arg(long)]
2824 allow_dirty: bool,
2825
2826 #[arg(long)]
2828 dry_run: bool,
2829
2830 #[arg(long)]
2834 ignore_upload_errors: bool,
2835 },
2836 ReviewPacket {
2847 #[arg(long, value_name = "KEY")]
2849 api_key: Option<String>,
2850
2851 #[arg(long, value_name = "URL")]
2853 api_endpoint: Option<String>,
2854
2855 #[arg(long, value_name = "OWNER/REPO")]
2860 repo: Option<String>,
2861
2862 #[arg(long, value_name = "ID")]
2864 project_id: Option<String>,
2865
2866 #[arg(long, value_name = "DAYS")]
2868 coverage_period: Option<u16>,
2869
2870 #[arg(long, value_name = "SHA")]
2872 commit_sha: Option<String>,
2873
2874 #[arg(long = "file", value_name = "PATH")]
2876 files: Vec<String>,
2877
2878 #[arg(long = "function", value_name = "FILE:NAME[:LINE]")]
2880 functions: Vec<String>,
2881 },
2882 DeploymentChanges {
2890 #[arg(long, value_name = "KEY")]
2892 api_key: Option<String>,
2893
2894 #[arg(long, value_name = "URL")]
2896 api_endpoint: Option<String>,
2897
2898 #[arg(long, value_name = "OWNER/REPO")]
2903 repo: Option<String>,
2904
2905 #[arg(long, value_name = "SHA")]
2907 sha: Option<String>,
2908
2909 #[arg(long, value_name = "KIND", value_parser = [
2911 "stopped", "new_not_called", "heated_up", "cooled_down", "new_called", "unchanged",
2912 ])]
2913 change: Option<String>,
2914
2915 #[arg(long, value_name = "N")]
2917 limit: Option<u16>,
2918
2919 #[arg(long, value_name = "CURSOR")]
2921 cursor: Option<String>,
2922 },
2923}
2924
2925#[derive(Subcommand)]
2926enum CiCli {
2927 PlanPrComment {
2929 #[arg(long)]
2931 body: PathBuf,
2932
2933 #[arg(long)]
2935 marker_id: String,
2936
2937 #[arg(long)]
2939 clean: bool,
2940
2941 #[arg(long)]
2943 existing_comment_id: Option<String>,
2944
2945 #[arg(long)]
2947 existing_body: Option<PathBuf>,
2948 },
2949
2950 PostPrComment {
2952 #[arg(long, value_enum)]
2954 provider: CiProviderArg,
2955
2956 #[arg(long)]
2958 pr: Option<String>,
2959
2960 #[arg(long)]
2962 mr: Option<String>,
2963
2964 #[arg(long)]
2966 body: PathBuf,
2967
2968 #[arg(long)]
2970 envelope: Option<PathBuf>,
2971
2972 #[arg(long)]
2974 marker_id: String,
2975
2976 #[arg(long)]
2978 clean: bool,
2979
2980 #[arg(long)]
2982 repo: Option<String>,
2983
2984 #[arg(long = "project-id")]
2986 project_id: Option<String>,
2987
2988 #[arg(long = "api-url")]
2990 api_url: Option<String>,
2991
2992 #[arg(long)]
2994 dry_run: bool,
2995 },
2996
2997 PostReview {
2999 #[arg(long, value_enum)]
3001 provider: CiProviderArg,
3002
3003 #[arg(long)]
3005 pr: Option<String>,
3006
3007 #[arg(long)]
3009 mr: Option<String>,
3010
3011 #[arg(long)]
3013 envelope: PathBuf,
3014
3015 #[arg(long)]
3017 repo: Option<String>,
3018
3019 #[arg(long = "project-id")]
3021 project_id: Option<String>,
3022
3023 #[arg(long = "api-url")]
3025 api_url: Option<String>,
3026
3027 #[arg(long)]
3029 dry_run: bool,
3030 },
3031
3032 PostCheckRun {
3034 #[arg(long, value_enum)]
3036 provider: CiProviderArg,
3037
3038 #[arg(long)]
3040 decision: PathBuf,
3041
3042 #[arg(long)]
3044 repo: String,
3045
3046 #[arg(long = "head-sha")]
3048 head_sha: String,
3049
3050 #[arg(long = "api-url")]
3052 api_url: Option<String>,
3053
3054 #[arg(long = "split-gates")]
3056 split_gates: bool,
3057
3058 #[arg(long)]
3060 dry_run: bool,
3061 },
3062
3063 ReconcileReview {
3065 #[arg(long, value_enum)]
3067 provider: CiProviderArg,
3068
3069 #[arg(long)]
3071 pr: Option<String>,
3072
3073 #[arg(long)]
3075 mr: Option<String>,
3076
3077 #[arg(long)]
3079 envelope: PathBuf,
3080
3081 #[arg(long)]
3083 repo: Option<String>,
3084
3085 #[arg(long = "project-id")]
3087 project_id: Option<String>,
3088
3089 #[arg(long = "api-url")]
3091 api_url: Option<String>,
3092
3093 #[arg(long)]
3095 dry_run: bool,
3096 },
3097}
3098
3099#[derive(Subcommand)]
3100enum RulePackCli {
3101 Init {
3103 name: Option<String>,
3105
3106 #[arg(long, default_value = "starter")]
3108 template: String,
3109
3110 #[arg(long, default_value = "rule-packs")]
3112 dir: String,
3113
3114 #[arg(long)]
3116 no_config: bool,
3117 },
3118
3119 List,
3121
3122 Test {
3124 pack: Option<PathBuf>,
3126 },
3127
3128 Schema,
3130}
3131
3132#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
3134pub enum BaselineModeArg {
3135 #[default]
3137 Count,
3138 Identity,
3141}
3142
3143impl From<BaselineModeArg> for fallow_engine::baseline::HealthBaselineMode {
3144 fn from(value: BaselineModeArg) -> Self {
3145 match value {
3146 BaselineModeArg::Count => Self::Count,
3147 BaselineModeArg::Identity => Self::Identity,
3148 }
3149 }
3150}
3151
3152#[derive(Clone, Copy, Debug, clap::ValueEnum)]
3153enum CiProviderArg {
3154 Github,
3155 Gitlab,
3156}
3157
3158#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
3160enum TypeAwareRequireArg {
3161 BestEffort,
3163 Complete,
3165}
3166
3167impl From<TypeAwareRequireArg> for fallow_config::TypeAwareRequire {
3168 fn from(value: TypeAwareRequireArg) -> Self {
3169 match value {
3170 TypeAwareRequireArg::BestEffort => Self::BestEffort,
3171 TypeAwareRequireArg::Complete => Self::Complete,
3172 }
3173 }
3174}
3175
3176#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
3178pub enum EffortFilter {
3179 Low,
3180 Medium,
3181 High,
3182}
3183
3184impl EffortFilter {
3185 const fn to_estimate(self) -> fallow_output::EffortEstimate {
3187 match self {
3188 Self::Low => fallow_output::EffortEstimate::Low,
3189 Self::Medium => fallow_output::EffortEstimate::Medium,
3190 Self::High => fallow_output::EffortEstimate::High,
3191 }
3192 }
3193}
3194
3195#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
3197pub enum HealthSeverityCli {
3198 Moderate,
3199 High,
3200 Critical,
3201}
3202
3203impl HealthSeverityCli {
3204 const fn to_health_severity(self) -> fallow_output::FindingSeverity {
3206 match self {
3207 Self::Moderate => fallow_output::FindingSeverity::Moderate,
3208 Self::High => fallow_output::FindingSeverity::High,
3209 Self::Critical => fallow_output::FindingSeverity::Critical,
3210 }
3211 }
3212}
3213
3214#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
3220pub enum EmailModeArg {
3221 Raw,
3223 Handle,
3225 Anonymized,
3227 #[value(hide = true)]
3229 Hash,
3230}
3231
3232impl EmailModeArg {
3233 const fn to_config(self) -> fallow_config::EmailMode {
3235 match self {
3236 Self::Raw => fallow_config::EmailMode::Raw,
3237 Self::Handle => fallow_config::EmailMode::Handle,
3238 Self::Anonymized => fallow_config::EmailMode::Anonymized,
3239 Self::Hash => fallow_config::EmailMode::Hash,
3240 }
3241 }
3242}
3243
3244#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
3246pub enum AuditGateArg {
3247 NewOnly,
3249 All,
3251}
3252
3253impl From<AuditGateArg> for fallow_config::AuditGate {
3254 fn from(value: AuditGateArg) -> Self {
3255 match value {
3256 AuditGateArg::NewOnly => Self::NewOnly,
3257 AuditGateArg::All => Self::All,
3258 }
3259 }
3260}
3261
3262fn parse_min_occurrences(s: &str) -> Result<usize, String> {
3266 let value: usize = s
3267 .parse()
3268 .map_err(|_| format!("`{s}` is not a non-negative integer"))?;
3269 if value < 2 {
3270 return Err(format!(
3271 "must be at least 2 (got {value}); a single occurrence isn't a duplicate"
3272 ));
3273 }
3274 Ok(value)
3275}
3276
3277fn resolve_audit_baseline_path(
3283 root: &std::path::Path,
3284 cli: Option<&std::path::Path>,
3285 config: Option<&str>,
3286) -> Option<PathBuf> {
3287 let path = cli.map(std::path::Path::to_path_buf).or_else(|| {
3288 config.map(|p| {
3289 let path = PathBuf::from(p);
3290 if path_util::is_absolute_path_any_platform(&path) {
3291 path
3292 } else {
3293 root.join(path)
3294 }
3295 })
3296 })?;
3297 if path_util::is_absolute_path_any_platform(&path) {
3298 Some(path)
3299 } else {
3300 Some(root.join(path))
3301 }
3302}
3303
3304fn emit_known_failure(
3305 message: &str,
3306 exit_code: u8,
3307 output: fallow_config::OutputFormat,
3308 reason: telemetry::FailureReason,
3309) -> ExitCode {
3310 telemetry::note_failure_reason(reason);
3311 emit_error(message, exit_code, output)
3312}
3313
3314fn emit_known_failure_with_style(
3315 message: &str,
3316 exit_code: u8,
3317 output: fallow_config::OutputFormat,
3318 json_style: json_style::JsonStyle,
3319 reason: telemetry::FailureReason,
3320) -> ExitCode {
3321 telemetry::note_failure_reason(reason);
3322 error::emit_error_with_style(message, exit_code, output, json_style)
3323}
3324
3325fn unsupported_security_global(cli: &Cli) -> Option<&'static str> {
3326 if cli.baseline.is_some() {
3327 Some("--baseline")
3328 } else if cli.save_baseline.is_some() {
3329 Some("--save-baseline")
3330 } else if cli.fail_on_stale_baseline {
3331 Some("--fail-on-stale-baseline")
3332 } else if cli.fail_on_baseline_growth {
3333 Some("--fail-on-baseline-growth")
3334 } else if cli.baseline_base.is_some() {
3335 Some("--baseline-base")
3336 } else if cli.fail_on_parse_error {
3337 Some("--fail-on-parse-error")
3338 } else if cli.production {
3339 Some("--production")
3340 } else if cli.no_production {
3341 Some("--no-production")
3342 } else if cli.no_package_baselines {
3343 Some("--no-package-baselines")
3344 } else if cli.group_by.is_some() {
3345 Some("--group-by")
3346 } else if cli.performance {
3347 Some("--performance")
3348 } else if cli.explain_skipped {
3349 Some("--explain-skipped")
3350 } else if cli.fail_on_regression {
3351 Some("--fail-on-regression")
3352 } else if cli.regression_baseline.is_some() {
3353 Some("--regression-baseline")
3354 } else if cli.save_regression_baseline.is_some() {
3355 Some("--save-regression-baseline")
3356 } else if let Some(flag) = cli.dupes_overrides().first_global_flag() {
3357 Some(flag)
3358 } else if cli.include_entry_exports {
3359 Some("--include-entry-exports")
3360 } else if cli.show_cascade {
3361 Some("--show-cascade")
3362 } else {
3363 None
3364 }
3365}
3366
3367struct DispatchContext<'a> {
3368 cli: &'a Cli,
3369 root: &'a std::path::Path,
3370 output: fallow_config::OutputFormat,
3371 quiet: bool,
3372 fail_on_issues: bool,
3373 json_style: json_style::JsonStyle,
3374 threads: usize,
3375 tolerance: regression::Tolerance,
3376 save_regression_file: Option<&'a std::path::PathBuf>,
3377 save_to_config: bool,
3378}
3379
3380impl DispatchContext<'_> {
3381 fn production_modes(
3382 &self,
3383 dead_code: bool,
3384 health: bool,
3385 dupes: bool,
3386 ) -> Result<ProductionModes, ExitCode> {
3387 resolve_production_modes(self.cli, self.root, self.output, dead_code, health, dupes)
3388 }
3389
3390 fn production_for(
3391 &self,
3392 analysis: fallow_config::ProductionAnalysis,
3393 ) -> Result<bool, ExitCode> {
3394 self.production_modes(false, false, false)
3395 .map(|modes| modes.for_analysis(analysis))
3396 }
3397
3398 fn growth_flags(&self) -> baseline_growth::GrowthFlags<'_> {
3399 baseline_growth::GrowthFlags {
3400 enabled: self.cli.fail_on_baseline_growth,
3401 base: self.cli.baseline_base.as_deref(),
3402 changed_since: self.cli.changed_since.as_deref(),
3403 }
3404 }
3405
3406 fn growth_ctx(
3407 &self,
3408 owner: baseline_growth::GrowthOwner,
3409 ) -> baseline_growth::GrowthContext<'_> {
3410 baseline_growth::GrowthContext {
3411 root: self.root,
3412 flags: self.growth_flags(),
3413 owner,
3414 output: self.output,
3415 json_style: self.json_style,
3416 }
3417 }
3418
3419 fn regression_opts(&self, scoped: bool) -> regression::RegressionOpts<'_> {
3420 regression::RegressionOpts {
3421 fail_on_regression: self.cli.fail_on_regression,
3422 tolerance: self.tolerance,
3423 regression_baseline_file: self.cli.regression_baseline.as_deref(),
3424 save_target: if let Some(path) = self.save_regression_file {
3425 regression::SaveRegressionTarget::File(path)
3426 } else if self.save_to_config {
3427 regression::SaveRegressionTarget::Config
3428 } else {
3429 regression::SaveRegressionTarget::None
3430 },
3431 scoped,
3432 quiet: self.quiet,
3433 output: self.output,
3434 }
3435 }
3436}
3437
3438#[cfg(unix)]
3453fn signal_test_helper() -> ExitCode {
3454 use std::io::Write as _;
3455 use std::process::Command;
3456
3457 if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER_GRACEFUL").is_some() {
3458 signal::set_graceful_mode();
3459 }
3460
3461 let mut command = Command::new("sleep");
3462 command.arg("30");
3463 let child = match signal::ScopedChild::spawn(&mut command) {
3464 Ok(c) => c,
3465 Err(err) => {
3466 let _ = writeln!(std::io::stderr(), "spawn sleep failed: {err}");
3467 return ExitCode::from(2);
3468 }
3469 };
3470 let pid = child.id();
3471 let stdout = std::io::stdout();
3472 let mut lock = stdout.lock();
3473 let _ = writeln!(lock, "{pid}");
3474 let _ = lock.flush();
3475 drop(lock);
3476 let _ = child.wait_with_output();
3477 if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER_GRACEFUL").is_some() {
3478 return ExitCode::SUCCESS;
3479 }
3480 std::thread::sleep(std::time::Duration::from_secs(5));
3481 ExitCode::SUCCESS
3482}
3483
3484#[cfg(not(unix))]
3485fn signal_test_helper() -> ExitCode {
3486 ExitCode::from(2)
3487}
3488
3489fn install_spawn_hooks() {
3490 fallow_engine::churn::set_spawn_hook(signal::scoped_child::output);
3491 fallow_engine::changed_files::set_spawn_hook(signal::scoped_child::output);
3492}
3493
3494fn install_signal_handlers() {
3495 if let Err(err) = signal::install_handlers() {
3496 use std::io::Write as _;
3497 let stderr = std::io::stderr();
3498 let mut lock = stderr.lock();
3499 let _ = writeln!(lock, "fallow: failed to install signal handlers: {err}");
3500 }
3501}
3502
3503fn redirect_report_to_file(
3508 path: &std::path::Path,
3509 output: fallow_config::OutputFormat,
3510) -> Result<(), ExitCode> {
3511 match fallow_engine::write_guard::create_file(
3512 path,
3513 fallow_engine::write_guard::WriteTarget::Path,
3514 ) {
3515 Ok(file) => {
3516 report::sink::set_file_sink(file);
3517 colored::control::set_override(false);
3518 Ok(())
3519 }
3520 Err(e) if e.is_directory() => Err(emit_error(
3521 &format!(
3522 "failed to create the directory of {} for --output-file: {e}",
3523 path.display()
3524 ),
3525 2,
3526 output,
3527 )),
3528 Err(e) => Err(emit_error(
3529 &format!("failed to open {} for --output-file: {e}", path.display()),
3530 2,
3531 output,
3532 )),
3533 }
3534}
3535
3536fn finalize_report_file(
3539 path: &std::path::Path,
3540 quiet: bool,
3541 output: fallow_config::OutputFormat,
3542) -> Result<(), ExitCode> {
3543 if let Err(e) = report::sink::flush() {
3544 return Err(emit_error(
3545 &format!("failed to write {}: {e}", path.display()),
3546 2,
3547 output,
3548 ));
3549 }
3550 if !quiet && report::sink::wrote() {
3554 eprintln!("Report written to {}", path.display());
3555 }
3556 Ok(())
3557}
3558
3559pub fn run() -> ExitCode {
3567 report::sink::finish_stdout(run_command())
3568}
3569
3570fn run_command() -> ExitCode {
3571 process_clock::mark_process_start();
3572 install_signal_handlers();
3573 install_spawn_hooks();
3574
3575 if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER").is_some() {
3576 return signal_test_helper();
3577 }
3578
3579 let (mut cli, fmt) = match parse_cli_args() {
3580 Ok(parsed) => parsed,
3581 Err(code) => return code,
3582 };
3583 if cli.pretty && !fmt.payload_is_json {
3584 eprintln!(
3585 "Error: --pretty requires JSON output. Use --format json --pretty, or remove --pretty."
3586 );
3587 return ExitCode::from(2);
3588 }
3589
3590 if let Some(code) = reject_global_baseline_flags(&cli, &fmt) {
3591 return code;
3592 }
3593
3594 if let Some(code) = run_schema_command_if_requested(&cli, fmt.json_style) {
3595 return code;
3596 }
3597
3598 if let Some(code) = run_telemetry_command_if_requested(&mut cli, fmt.output, fmt.json_style) {
3599 return code;
3600 }
3601 if let Some(code) = run_doctor_command_if_requested(&cli, &fmt) {
3602 return code;
3603 }
3604 if is_impact_statusline(&cli) {
3605 let (root, _) = match validate_inputs(&cli, fmt.output, fmt.json_style) {
3606 Ok(validated) => validated,
3607 Err(code) => return code,
3608 };
3609 return cli_impact::render_impact_statusline(&root);
3610 }
3611 let telemetry_run = start_telemetry_run(&cli, &fmt);
3612
3613 let (root, threads) = match validate_inputs(&cli, fmt.output, fmt.json_style) {
3614 Ok(v) => v,
3615 Err(code) => {
3616 return record_run_epilogue(telemetry_run, code, None, cli.parent_run.as_deref(), None);
3617 }
3618 };
3619
3620 let FormatConfig {
3621 output,
3622 payload_is_json: _,
3623 quiet,
3624 fail_on_issues,
3625 json_style,
3626 } = fmt;
3627
3628 let tolerance =
3629 match run_pre_dispatch_checks(&cli, &root, output, json_style, quiet, telemetry_run) {
3630 Ok(tolerance) => tolerance,
3631 Err(code) => return code,
3632 };
3633
3634 if let Some(note) = write_scope::default_cache_dir_note(&cli, &root) {
3635 eprintln!("{note}");
3636 cli.no_cache = true;
3637 }
3638
3639 let (save_regression_file, save_to_config) = regression_save_targets(&cli);
3640
3641 let hint_root = (!matches!(
3645 cli.command,
3646 Some(Command::Agent { .. } | Command::Hooks { .. } | Command::SetupHooks { .. })
3647 ))
3648 .then_some(root.as_path());
3649 let command = cli.command.take();
3650 process_clock::record_startup();
3651 let dispatch = DispatchContext {
3652 cli: &cli,
3653 root: &root,
3654 output,
3655 quiet,
3656 fail_on_issues,
3657 json_style,
3658 threads,
3659 tolerance,
3660 save_regression_file: save_regression_file.as_ref(),
3661 save_to_config,
3662 };
3663 let exit_code = match dispatch_and_finalize(&dispatch, command) {
3664 Ok(code) => code,
3665 Err(code) => return code,
3666 };
3667 record_run_epilogue(
3668 telemetry_run,
3669 exit_code,
3670 None,
3671 cli.parent_run.as_deref(),
3672 hint_root,
3673 )
3674}
3675
3676#[doc(hidden)]
3680pub fn benchmark_fix_dry_run(root: &Path, threads: usize) -> (ExitCode, usize) {
3681 let config_path = None;
3682 fix::run_fix_with_count(&fix::FixOptions {
3683 root,
3684 config_path: &config_path,
3685 output: fallow_config::OutputFormat::Json,
3686 json_style: json_style::JsonStyle::Compact,
3687 no_cache: true,
3688 threads,
3689 quiet: true,
3690 emit_output: false,
3691 allow_remote_extends: false,
3692 dry_run: true,
3693 yes: false,
3694 production: false,
3695 no_create_config: true,
3696 type_aware: None,
3697 type_aware_projects: &[],
3698 type_aware_require: None,
3699 scope: None,
3700 show_cascade: false,
3701 })
3702}
3703
3704#[doc(hidden)]
3707pub use audit::AuditReviewBenchmarkCorpus;
3708
3709#[doc(hidden)]
3712pub fn create_audit_review_benchmark_corpus(
3713 root: &Path,
3714 changed_files: &[PathBuf],
3715 threads: usize,
3716) -> Result<AuditReviewBenchmarkCorpus, ExitCode> {
3717 audit::create_audit_review_benchmark_corpus(root, changed_files, threads)
3718}
3719
3720#[doc(hidden)]
3723pub fn benchmark_audit_review_brief_many_changed_files_json(
3724 corpus: &mut AuditReviewBenchmarkCorpus,
3725) -> (ExitCode, usize, usize, usize, usize, usize) {
3726 match audit::benchmark_audit_review_brief_many_changed_files_json(corpus) {
3727 Ok(result) => (
3728 ExitCode::SUCCESS,
3729 result.introduced_count,
3730 result.inherited_count,
3731 result.public_api_added_count,
3732 result.decision_count,
3733 result.rendered_bytes,
3734 ),
3735 Err(code) => (code, 0, 0, 0, 0, 0),
3736 }
3737}
3738
3739#[doc(hidden)]
3740pub use inspect::InspectBenchmarkCorpus;
3741
3742#[doc(hidden)]
3745pub fn create_inspect_benchmark_corpus(root: &Path, threads: usize) -> InspectBenchmarkCorpus {
3746 inspect::create_inspect_benchmark_corpus(root, threads)
3747}
3748
3749#[doc(hidden)]
3752pub fn benchmark_inspect_file_evidence_bundle_json(
3753 root: &Path,
3754 threads: usize,
3755 corpus: &InspectBenchmarkCorpus,
3756) -> (ExitCode, usize, usize) {
3757 match inspect::benchmark_inspect_file_evidence_bundle_json(root, threads, corpus) {
3758 Ok((child_call_count, rendered_bytes)) => {
3759 (ExitCode::SUCCESS, child_call_count, rendered_bytes)
3760 }
3761 Err(_) => (ExitCode::from(2), 0, 0),
3762 }
3763}
3764
3765#[doc(hidden)]
3768pub fn benchmark_dead_code_json(root: &Path, threads: usize) -> (ExitCode, usize, usize) {
3769 match check::benchmark_dead_code_json(root, threads) {
3770 Ok((issue_count, rendered_bytes)) => (ExitCode::SUCCESS, issue_count, rendered_bytes),
3771 Err(code) => (code, 0, 0),
3772 }
3773}
3774
3775#[doc(hidden)]
3778pub fn benchmark_security_json(root: &Path, threads: usize) -> (ExitCode, usize, usize) {
3779 match security::benchmark_security_json(root, threads) {
3780 Ok((finding_count, rendered_bytes)) => (ExitCode::SUCCESS, finding_count, rendered_bytes),
3781 Err(code) => (code, 0, 0),
3782 }
3783}
3784
3785#[doc(hidden)]
3786pub use security::{SecurityBlindSpotsBenchmarkResult, SecuritySurvivorsBenchmarkCorpus};
3787
3788#[doc(hidden)]
3791pub fn create_security_survivors_benchmark_corpus(
3792 root: &Path,
3793 threads: usize,
3794) -> Result<SecuritySurvivorsBenchmarkCorpus, ExitCode> {
3795 security::create_security_survivors_benchmark_corpus(root, threads)
3796}
3797
3798#[doc(hidden)]
3801pub fn benchmark_security_survivors_json(
3802 corpus: &SecuritySurvivorsBenchmarkCorpus,
3803) -> (ExitCode, usize, usize, usize, usize, usize) {
3804 match security::benchmark_security_survivors_json(corpus) {
3805 Ok((survivors, dismissed, needs_human_review, unverdicted, rendered_bytes)) => (
3806 ExitCode::SUCCESS,
3807 survivors,
3808 dismissed,
3809 needs_human_review,
3810 unverdicted,
3811 rendered_bytes,
3812 ),
3813 Err(_) => (ExitCode::from(2), 0, 0, 0, 0, 0),
3814 }
3815}
3816
3817#[doc(hidden)]
3820pub fn benchmark_security_blind_spots_json(
3821 root: &Path,
3822 diagnostics: &[fallow_types::results::SecurityUnresolvedCalleeDiagnostic],
3823) -> SecurityBlindSpotsBenchmarkResult {
3824 security::benchmark_security_blind_spots_json(root, diagnostics)
3825}
3826
3827#[doc(hidden)]
3830pub fn benchmark_list_json(root: &Path, threads: usize) -> (ExitCode, usize, usize, usize, usize) {
3831 match list::benchmark_list_json(root, threads) {
3832 Ok((file_count, entry_point_count, workspace_count, rendered_bytes)) => (
3833 ExitCode::SUCCESS,
3834 file_count,
3835 entry_point_count,
3836 workspace_count,
3837 rendered_bytes,
3838 ),
3839 Err(code) => (code, 0, 0, 0, 0),
3840 }
3841}
3842
3843#[doc(hidden)]
3846pub fn benchmark_list_boundaries_json(
3847 root: &Path,
3848 threads: usize,
3849) -> (ExitCode, usize, usize, usize, usize) {
3850 match list::benchmark_list_boundaries_json(root, threads) {
3851 Ok((zone_count, rule_count, matched_file_count, rendered_bytes)) => (
3852 ExitCode::SUCCESS,
3853 zone_count,
3854 rule_count,
3855 matched_file_count,
3856 rendered_bytes,
3857 ),
3858 Err(code) => (code, 0, 0, 0, 0),
3859 }
3860}
3861
3862#[doc(hidden)]
3865pub use watch::WatchFilterBenchmarkGlobalGitignore;
3866
3867#[doc(hidden)]
3870pub fn create_watch_filter_benchmark_global_gitignore() -> WatchFilterBenchmarkGlobalGitignore {
3871 watch::create_benchmark_global_gitignore()
3872}
3873
3874#[doc(hidden)]
3877pub fn benchmark_watch_filter_initialization(
3878 config: &fallow_config::ResolvedConfig,
3879 global_gitignore: &WatchFilterBenchmarkGlobalGitignore,
3880) -> (usize, usize) {
3881 watch::benchmark_filter_initialization(config, global_gitignore)
3882}
3883
3884#[doc(hidden)]
3887pub fn benchmark_viz_html(root: &Path, threads: usize) -> (ExitCode, usize, usize, usize) {
3888 match viz::benchmark_viz_html(root, threads) {
3889 Ok((file_count, edge_count, rendered_bytes)) => {
3890 (ExitCode::SUCCESS, file_count, edge_count, rendered_bytes)
3891 }
3892 Err(code) => (code, 0, 0, 0),
3893 }
3894}
3895
3896#[doc(hidden)]
3899pub fn benchmark_rule_pack_test_json(root: &Path, threads: usize) -> (ExitCode, usize, usize) {
3900 match rule_pack::benchmark_rule_pack_test_json(root, threads) {
3901 Ok((finding_count, rendered_bytes)) => (ExitCode::SUCCESS, finding_count, rendered_bytes),
3902 Err(code) => (code, 0, 0),
3903 }
3904}
3905
3906#[doc(hidden)]
3909pub fn benchmark_recommend_json(root: &Path) -> (ExitCode, usize, usize, bool, usize) {
3910 match onboarding::benchmark_recommend_json(root) {
3911 Ok((decision_count, framework_count, heterogeneous, rendered_bytes)) => (
3912 ExitCode::SUCCESS,
3913 decision_count,
3914 framework_count,
3915 heterogeneous,
3916 rendered_bytes,
3917 ),
3918 Err(_) => (ExitCode::from(2), 0, 0, false, 0),
3919 }
3920}
3921
3922#[doc(hidden)]
3925pub fn benchmark_runtime_coverage_analyze_json(
3926 root: &Path,
3927 runtime_coverage_path: &Path,
3928 response_bytes: &[u8],
3929 threads: usize,
3930) -> (ExitCode, usize, usize, usize, String) {
3931 match coverage::benchmark_local_json(root, runtime_coverage_path, response_bytes, threads) {
3932 Ok((finding_count, hot_path_count, request_bytes, rendered)) => (
3933 ExitCode::SUCCESS,
3934 finding_count,
3935 hot_path_count,
3936 request_bytes,
3937 rendered,
3938 ),
3939 Err(code) => (code, 0, 0, 0, String::new()),
3940 }
3941}
3942
3943fn is_impact_statusline(cli: &Cli) -> bool {
3946 matches!(
3947 cli.command.as_ref(),
3948 Some(Command::Impact {
3949 subcommand: Some(ImpactCli::Statusline),
3950 all: false,
3951 ..
3952 })
3953 )
3954}
3955
3956fn dispatch_and_finalize(
3960 dispatch: &DispatchContext<'_>,
3961 command: Option<Command>,
3962) -> Result<ExitCode, ExitCode> {
3963 let cli = dispatch.cli;
3964 let output = dispatch.output;
3965 let quiet = dispatch.quiet;
3966
3967 if let Some(path) = cli.output_file.as_deref()
3970 && let Err(code) = redirect_report_to_file(path, output)
3971 {
3972 return Err(code);
3973 }
3974
3975 let exit_code = if command.is_some() && cli_has_bare_coverage_input(cli) {
3976 emit_error(bare_coverage_subcommand_error_message(), 2, output)
3977 } else if command.is_some()
3978 && let Some(flag) = cli_bare_combined_baseline_flag(cli)
3979 {
3980 emit_error(
3981 &bare_combined_baseline_subcommand_error_message(flag),
3982 2,
3983 output,
3984 )
3985 } else {
3986 match command {
3987 None => dispatch_bare_command(dispatch),
3988 Some(cmd) => dispatch_subcommand(cmd, dispatch),
3989 }
3990 };
3991
3992 if let Some(path) = cli.output_file.as_deref()
3993 && let Err(code) = finalize_report_file(path, quiet, output)
3994 {
3995 return Err(code);
3996 }
3997 Ok(exit_code)
3998}
3999
4000fn run_telemetry_command_if_requested(
4001 cli: &mut Cli,
4002 output: fallow_config::OutputFormat,
4003 json_style: json_style::JsonStyle,
4004) -> Option<ExitCode> {
4005 if matches!(cli.command, Some(Command::Telemetry { .. }))
4006 && let Some(Command::Telemetry { subcommand }) = cli.command.take()
4007 {
4008 return Some(telemetry::run(
4009 map_telemetry_subcommand(subcommand),
4010 output,
4011 json_style,
4012 ));
4013 }
4014 None
4015}
4016
4017fn run_doctor_command_if_requested(cli: &Cli, format: &FormatConfig) -> Option<ExitCode> {
4022 if !matches!(cli.command, Some(Command::Doctor)) {
4023 return None;
4024 }
4025
4026 if let Some(flag) = unsupported_doctor_option(cli) {
4027 let message = if flag == "--output-file" {
4028 "--output-file is not valid with `fallow doctor`; doctor is read-only and writes its report to stdout".to_string()
4029 } else {
4030 format!("{flag} is not valid with `fallow doctor`.")
4031 };
4032 return Some(crate::error::emit_error_with_style(
4033 &message,
4034 2,
4035 format.output,
4036 format.json_style,
4037 ));
4038 }
4039 if let Err(code) = doctor::validate_output(format.output, format.json_style) {
4040 return Some(code);
4041 }
4042
4043 let root = cli.root.clone().unwrap_or_else(|| {
4044 std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
4045 });
4046 let config_path = cli.config.as_ref().map(|path| {
4047 if path_util::is_absolute_path_any_platform(path) {
4048 path.clone()
4049 } else {
4050 root.join(path)
4051 }
4052 });
4053 let report = doctor::collect_report(&root, config_path.as_deref());
4054 Some(doctor::render_report(
4055 &report,
4056 format.output,
4057 format.json_style,
4058 ))
4059}
4060
4061fn unsupported_doctor_option(cli: &Cli) -> Option<&'static str> {
4065 let dupes_flag = cli.dupes_overrides().first_global_flag();
4066 [
4067 (cli.allow_remote_extends, "--allow-remote-extends"),
4068 (cli.no_cache, "--no-cache"),
4069 (cli.threads.is_some(), "--threads"),
4070 (cli.changed_since.is_some(), "--changed-since"),
4071 (cli.diff_file.is_some(), "--diff-file"),
4072 (cli.diff_stdin, "--diff-stdin"),
4073 (cli.churn_file.is_some(), "--churn-file"),
4074 (cli.max_file_size.is_some(), "--max-file-size"),
4075 (cli.baseline.is_some(), "--baseline"),
4076 (cli.baseline_mode.is_some(), "--baseline-mode"),
4077 (cli.parent_run.is_some(), "--parent-run"),
4078 (cli.save_baseline.is_some(), "--save-baseline"),
4079 (cli.production, "--production"),
4080 (cli.no_production, "--no-production"),
4081 (cli.no_package_baselines, "--no-package-baselines"),
4082 (cli.production_dead_code, "--production-dead-code"),
4083 (cli.production_health, "--production-health"),
4084 (cli.production_dupes, "--production-dupes"),
4085 (cli.workspace.is_some(), "--workspace"),
4086 (cli.changed_workspaces.is_some(), "--changed-workspaces"),
4087 (cli.group_by.is_some(), "--group-by"),
4088 (cli.performance, "--performance"),
4089 (cli.explain, "--explain"),
4090 (cli.explain_skipped, "--explain-skipped"),
4091 (cli.summary, "--summary"),
4092 (cli.ci, "--ci"),
4093 (cli.fail_on_issues, "--fail-on-issues"),
4094 (cli.sarif_file.is_some(), "--sarif-file"),
4095 (cli.output_file.is_some(), "--output-file"),
4096 (cli.report_path_prefix.is_some(), "--report-path-prefix"),
4097 (cli.fail_on_regression, "--fail-on-regression"),
4098 (cli.fail_on_stale_baseline, "--fail-on-stale-baseline"),
4099 (cli.fail_on_baseline_growth, "--fail-on-baseline-growth"),
4100 (cli.baseline_base.is_some(), "--baseline-base"),
4101 (cli.fail_on_parse_error, "--fail-on-parse-error"),
4102 (cli.tolerance != "0", "--tolerance"),
4103 (cli.regression_baseline.is_some(), "--regression-baseline"),
4104 (
4105 cli.save_regression_baseline.is_some(),
4106 "--save-regression-baseline",
4107 ),
4108 (!cli.only.is_empty(), "--only"),
4109 (!cli.skip.is_empty(), "--skip"),
4110 (dupes_flag.is_some(), dupes_flag.unwrap_or_default()),
4111 (cli.score, "--score"),
4112 (cli.trend, "--trend"),
4113 (cli.trend_from.is_some(), "--trend-from"),
4114 (cli.save_snapshot.is_some(), "--save-snapshot"),
4115 (cli.coverage.is_some(), "--coverage"),
4116 (cli.coverage_root.is_some(), "--coverage-root"),
4117 (cli.dupes_baseline.is_some(), "--dupes-baseline"),
4118 (cli.health_baseline.is_some(), "--health-baseline"),
4119 (cli.include_entry_exports, "--include-entry-exports"),
4120 (cli.show_cascade, "--show-cascade"),
4121 (cli.type_aware, "--type-aware"),
4122 (cli.no_type_aware, "--no-type-aware"),
4123 (!cli.type_aware_project.is_empty(), "--type-aware-project"),
4124 (cli.type_aware_require.is_some(), "--type-aware-require"),
4125 ]
4126 .into_iter()
4127 .find_map(|(used, flag)| used.then_some(flag))
4128}
4129
4130fn run_schema_command_if_requested(
4131 cli: &Cli,
4132 json_style: json_style::JsonStyle,
4133) -> Option<ExitCode> {
4134 match cli.command {
4135 Some(Command::Schema) => Some(schema::run_schema(json_style)),
4136 Some(Command::ConfigSchema) => Some(init::run_config_schema(json_style)),
4137 Some(Command::PluginSchema) => Some(init::run_plugin_schema(json_style)),
4138 Some(Command::RulePackSchema) => Some(init::run_rule_pack_schema(json_style)),
4139 _ => None,
4140 }
4141}
4142
4143fn regression_save_targets(cli: &Cli) -> (Option<std::path::PathBuf>, bool) {
4144 let save_file = cli.save_regression_baseline.as_ref().and_then(|opt| {
4145 opt.as_ref()
4146 .filter(|path| !path.is_empty())
4147 .map(std::path::PathBuf::from)
4148 });
4149 let save_to_config = cli.save_regression_baseline.is_some() && save_file.is_none();
4150 (save_file, save_to_config)
4151}
4152
4153fn dispatch_bare_command(dispatch: &DispatchContext<'_>) -> ExitCode {
4154 let cli = dispatch.cli;
4155 let (run_check, run_dupes, run_health) = combined::resolve_analyses(&cli.only, &cli.skip);
4156 let production = match dispatch.production_modes(
4157 cli.production_dead_code,
4158 cli.production_health,
4159 cli.production_dupes,
4160 ) {
4161 Ok(production) => production,
4162 Err(code) => return code,
4163 };
4164 let coverage_inputs = if run_health {
4169 match resolve_health_coverage_inputs(
4170 dispatch,
4171 cli.coverage.as_deref(),
4172 cli.coverage_root.as_deref(),
4173 ) {
4174 Ok(inputs) => inputs,
4175 Err(code) => return code,
4176 }
4177 } else {
4178 ResolvedHealthCoverageInputs::default()
4179 };
4180 run_bare_combined(
4181 dispatch,
4182 production,
4183 &coverage_inputs,
4184 BareAnalyses {
4185 run_check,
4186 run_dupes,
4187 run_health,
4188 },
4189 )
4190}
4191
4192#[derive(Clone, Copy)]
4194struct BareAnalyses {
4195 run_check: bool,
4196 run_dupes: bool,
4197 run_health: bool,
4198}
4199
4200fn run_bare_combined(
4203 dispatch: &DispatchContext<'_>,
4204 production: ProductionModes,
4205 coverage_inputs: &ResolvedHealthCoverageInputs,
4206 analyses: BareAnalyses,
4207) -> ExitCode {
4208 let cli = dispatch.cli;
4209 let output = dispatch.output;
4210 if cli.fail_on_parse_error && !analyses.run_check && !analyses.run_health {
4211 return error::emit_error_with_style(
4212 "--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.",
4213 2,
4214 output,
4215 dispatch.json_style,
4216 );
4217 }
4218 let scope = match crate::scope_path::resolve_command_scope(
4219 dispatch.root,
4220 dispatch.output,
4221 cli.path.clone(),
4222 ) {
4223 Ok(scope) => scope.map(|resolved| resolved.absolute),
4224 Err(code) => return code,
4225 };
4226 let growth_targets = baseline_growth::targets(&[
4227 (
4228 cli.baseline.as_deref().filter(|_| analyses.run_check),
4229 fallow_engine::baseline::BaselineKind::DeadCode,
4230 ),
4231 (
4232 cli.dupes_baseline.as_deref().filter(|_| analyses.run_dupes),
4233 fallow_engine::baseline::BaselineKind::Dupes,
4234 ),
4235 (
4236 cli.health_baseline
4237 .as_deref()
4238 .filter(|_| analyses.run_health),
4239 fallow_engine::baseline::BaselineKind::Health,
4240 ),
4241 ]);
4242 baseline_growth::run_gated(
4243 &dispatch.growth_ctx(baseline_growth::GrowthOwner::Combined),
4244 &growth_targets,
4245 || run_combined_scoped(dispatch, production, coverage_inputs, analyses, scope),
4246 )
4247}
4248
4249fn run_combined_scoped(
4250 dispatch: &DispatchContext<'_>,
4251 production: ProductionModes,
4252 coverage_inputs: &ResolvedHealthCoverageInputs,
4253 analyses: BareAnalyses,
4254 scope: Option<PathBuf>,
4255) -> ExitCode {
4256 let cli = dispatch.cli;
4257 let (output, quiet, fail_on_issues) =
4258 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
4259 let scoped_run = scope.is_some();
4260 let architecture = combined::resolve_architecture_selection(&cli.only, &cli.skip);
4261 let narrowed_report = architecture != check::ArchitectureSelection::All;
4263 combined::run_combined(&combined::CombinedOptions {
4264 root: dispatch.root,
4265 config_path: &cli.config,
4266 output,
4267 json_style: dispatch.json_style,
4268 no_cache: cli.no_cache,
4269 threads: dispatch.threads,
4270 quiet,
4271 allow_remote_extends: cli.allow_remote_extends,
4272 fail_on_issues,
4273 sarif_file: cli.sarif_file.as_deref(),
4274 changed_since: cli.changed_since.as_deref(),
4275 no_package_baselines: cli.no_package_baselines,
4276 churn_file: cli.churn_file.as_deref(),
4277 baseline: cli.baseline.as_deref(),
4278 save_baseline: cli.save_baseline.as_deref(),
4279 dupes_baseline: cli.dupes_baseline.as_deref(),
4280 health_baseline: cli.health_baseline.as_deref(),
4281 health_baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
4282 health_baseline_mode_explicit: cli.baseline_mode.is_some(),
4283 fail_on_stale_baseline: cli.fail_on_stale_baseline,
4284 production: cli.production,
4285 production_dead_code: Some(production.dead_code),
4286 production_health: Some(production.health),
4287 production_dupes: Some(production.dupes),
4288 workspace: cli.workspace.as_deref(),
4289 changed_workspaces: cli.changed_workspaces.as_deref(),
4290 group_by: cli.group_by,
4291 type_aware: cli.type_aware_override(),
4292 type_aware_projects: &cli.type_aware_project,
4293 type_aware_require: cli.type_aware_require.map(Into::into),
4294 explain: cli.explain,
4295 explain_skipped: cli.explain_skipped,
4296 performance: cli.performance,
4297 summary: cli.summary,
4298 run_check: analyses.run_check,
4299 run_dupes: analyses.run_dupes,
4300 run_health: analyses.run_health,
4301 architecture,
4302 dupes: cli.dupes_overrides(),
4303 score: cli.score || cli.trend || cli.trend_from.is_some(),
4304 trend: cli.trend || cli.trend_from.is_some(),
4305 trend_from: cli.trend_from.as_deref(),
4306 save_snapshot: cli.save_snapshot.as_ref(),
4307 coverage: coverage_inputs.coverage.as_deref(),
4308 coverage_root: coverage_inputs.coverage_root.as_deref(),
4309 include_entry_exports: cli.include_entry_exports,
4310 show_cascade: cli.show_cascade,
4311 fail_on_parse_error: cli.fail_on_parse_error,
4312 scope,
4313 regression_opts: dispatch.regression_opts(
4314 cli.changed_since.is_some()
4315 || cli.workspace.is_some()
4316 || cli.changed_workspaces.is_some()
4317 || scoped_run
4318 || narrowed_report,
4319 ),
4320 })
4321}
4322
4323#[allow(
4324 clippy::too_many_lines,
4325 reason = "the command router is intentionally an exhaustive top-level dispatch table"
4326)]
4327fn dispatch_subcommand(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4328 let cli = dispatch.cli;
4329 let root = dispatch.root;
4330 let output = dispatch.output;
4331 let quiet = dispatch.quiet;
4332 match command {
4333 check @ Command::Check { .. } => dispatch_check_command(check, dispatch),
4334 architecture @ Command::Architecture { .. } => {
4335 dispatch_architecture_command(architecture, dispatch)
4336 }
4337 Command::Watch { no_clear } => dispatch_watch(dispatch, no_clear),
4338 Command::TypeAware { subcommand } => dispatch_type_aware_command(dispatch, subcommand),
4339 Command::Doctor => unreachable!("doctor bypasses the normal dispatch epilogue"),
4340 Command::SimilarCode {
4341 subcommand,
4342 threshold,
4343 min_lines,
4344 top,
4345 file,
4346 path,
4347 } => {
4348 let scope = match crate::scope_path::resolve_command_scope(
4349 dispatch.root,
4350 dispatch.output,
4351 path,
4352 ) {
4353 Ok(scope) => scope,
4354 Err(code) => return code,
4355 };
4356 similar_code_cli::run(similar_code_cli::SimilarCodeCliInput {
4357 root,
4358 config_path: cli.config.as_deref(),
4359 allow_remote_extends: cli.allow_remote_extends,
4360 no_cache: cli.no_cache,
4361 threads: dispatch.threads,
4362 changed_since: cli.changed_since.as_deref(),
4363 diff_file: cli.diff_file.as_deref(),
4364 workspace: cli.workspace.as_deref(),
4365 changed_workspaces: cli.changed_workspaces.as_deref(),
4366 explain: cli.explain,
4367 quiet,
4368 output,
4369 json_style: dispatch.json_style,
4370 threshold,
4371 min_lines,
4372 top,
4373 files: file,
4374 scope,
4375 subcommand,
4376 })
4377 }
4378 Command::Inspect {
4379 file,
4380 symbol,
4381 symbol_chain,
4382 churn,
4383 } => dispatch_inspect_command(dispatch, file, symbol, symbol_chain, churn),
4384 Command::Trace {
4385 symbol,
4386 dependency,
4387 specifier,
4388 sites,
4389 limit,
4390 cursor,
4391 path,
4392 eager_only,
4393 callers,
4394 callees,
4395 depth,
4396 } => {
4397 if let Some(package_name) = dependency {
4398 return dispatch_trace_dependency(
4399 dispatch,
4400 trace_dependency::TraceDependencyTarget {
4401 package_name,
4402 specifiers: specifier,
4403 sites,
4404 limit,
4405 cursor,
4406 callers,
4407 depth,
4408 },
4409 );
4410 }
4411 dispatch_trace_command(
4412 dispatch,
4413 symbol,
4414 &path,
4415 eager_only,
4416 TraceChainFlags {
4417 callers,
4418 callees,
4419 depth,
4420 },
4421 )
4422 }
4423 Command::TraceError { trace_file } => {
4424 trace_error::run_trace_error(&trace_error::TraceErrorOptions {
4425 root: dispatch.root,
4426 config_path: &dispatch.cli.config,
4427 output: dispatch.output,
4428 json_style: dispatch.json_style,
4429 no_cache: dispatch.cli.no_cache,
4430 threads: dispatch.threads,
4431 quiet: dispatch.quiet,
4432 allow_remote_extends: dispatch.cli.allow_remote_extends,
4433 trace_file: trace_file.as_deref(),
4434 })
4435 }
4436 fix @ Command::Fix { .. } => dispatch_fix_command(&fix, dispatch),
4437 init @ Command::Init { .. } => dispatch_init_command(init, root, quiet),
4438 Command::Hooks { subcommand } => {
4439 run_hooks_command(root, subcommand, output, dispatch.json_style)
4440 }
4441 Command::Agent { subcommand } => dispatch_agent_command(dispatch, subcommand),
4442 Command::Ci { subcommand } => {
4443 ci::run(map_ci_subcommand(subcommand), output, dispatch.json_style)
4444 }
4445 Command::ConfigSchema => init::run_config_schema(dispatch.json_style),
4446 Command::PluginSchema => init::run_plugin_schema(dispatch.json_style),
4447 Command::PluginCheck => plugin_check::run_plugin_check(root, output, dispatch.json_style),
4448 Command::RulePackSchema => init::run_rule_pack_schema(dispatch.json_style),
4449 Command::RulePack { subcommand } => dispatch_rule_pack_command(dispatch, subcommand),
4450 Command::Guard { files } => dispatch_guard_command(dispatch, &files),
4451 Command::CiTemplate { subcommand } => dispatch_ci_template_command(subcommand),
4452 Command::Config { path } => config::run_config_with_options(config::RunConfigInput {
4453 root,
4454 explicit_config: cli.config.as_deref(),
4455 path_only: path,
4456 output,
4457 quiet,
4458 json_style: dispatch.json_style,
4459 load_options: fallow_config::ConfigLoadOptions {
4460 allow_remote_extends: cli.allow_remote_extends,
4461 },
4462 }),
4463 Command::Recommend => onboarding::run_recommend(root, output, dispatch.json_style),
4464 list @ (Command::Workspaces | Command::List { .. }) => {
4465 dispatch_list_command(&list, dispatch)
4466 }
4467 dupes @ Command::Dupes { .. } => dispatch_dupes_command(dupes, dispatch),
4468 health @ Command::Health { .. } => dispatch_health_command(health, dispatch),
4469 Command::Flags {
4470 top,
4471 retirement,
4472 reasons,
4473 sort,
4474 flag_age,
4475 min_age,
4476 flag_state,
4477 max_flag_age,
4478 } => dispatch_flags_command(
4479 dispatch,
4480 top,
4481 retirement.then_some(flags::RetirementArgs {
4482 reasons,
4483 sort,
4484 flag_age,
4485 min_age,
4486 flag_state,
4487 max_flag_age,
4488 }),
4489 ),
4490 Command::Suppressions { file } => dispatch_suppressions_command(dispatch, &file),
4491 Command::Explain { issue_type } => {
4492 explain::run_explain(&issue_type.join(" "), output, dispatch.json_style)
4493 }
4494 audit @ Command::Audit { .. } => dispatch_audit_command(audit, dispatch),
4495 Command::AuditCache { subcommand } => dispatch_audit_cache_command(dispatch, &subcommand),
4496 Command::Baselines { subcommand } => dispatch_baselines_command(dispatch, &subcommand),
4497 Command::DecisionSurface { max_decisions } => {
4498 dispatch_decision_surface(dispatch, max_decisions)
4499 }
4500 Command::Impact {
4501 subcommand,
4502 all,
4503 sort,
4504 limit,
4505 } => dispatch_impact(
4506 root,
4507 quiet,
4508 output,
4509 dispatch.json_style,
4510 subcommand,
4511 ImpactCrossRepoOpts { all, sort, limit },
4512 ),
4513 security @ Command::Security { .. } => dispatch_security_command(security, dispatch),
4514 Command::Viz {
4515 output: viz_output,
4516 no_open,
4517 viz_format,
4518 } => dispatch_viz(dispatch, viz_output.as_deref(), no_open, viz_format),
4519 Command::Report { from } => {
4520 cli_report::run_report(&from, output, root, cli.config.as_deref(), cli.quiet)
4521 }
4522 Command::Schema => unreachable!("handled above"),
4523 migrate @ Command::Migrate { .. } => dispatch_migrate_command(migrate, root),
4524 Command::License { subcommand } => {
4525 dispatch_license_command(subcommand, output, dispatch.json_style)
4526 }
4527 Command::Telemetry { .. } => unreachable!("handled before root validation"),
4528 Command::Coverage { subcommand } => dispatch_coverage_command(dispatch, &subcommand),
4529 setup_hooks @ Command::SetupHooks { .. } => {
4530 dispatch_setup_hooks_command(&setup_hooks, dispatch)
4531 }
4532 }
4533}
4534
4535fn dispatch_type_aware_command(
4536 dispatch: &DispatchContext<'_>,
4537 subcommand: TypeAwareCli,
4538) -> ExitCode {
4539 match subcommand {
4540 TypeAwareCli::Status => {
4541 let status = fallow_api::type_aware_status(dispatch.root);
4542 match dispatch.output {
4543 fallow_config::OutputFormat::Json => {
4544 let output = type_aware_status_output(dispatch.root, status);
4545 match fallow_output::serialize_type_aware_status_json_output(output) {
4546 Ok(value) => match dispatch.json_style.serialize(&value) {
4547 Ok(json) => {
4548 crate::report::sink::outln!("{json}");
4549 ExitCode::SUCCESS
4550 }
4551 Err(error) => emit_error(
4552 &format!("failed to serialize type-aware status: {error}"),
4553 2,
4554 dispatch.output,
4555 ),
4556 },
4557 Err(error) => emit_error(
4558 &format!("failed to build type-aware status: {error}"),
4559 2,
4560 dispatch.output,
4561 ),
4562 }
4563 }
4564 fallow_config::OutputFormat::Human => {
4565 if status.available {
4566 crate::report::sink::outln!(
4567 "{}",
4568 report::human_status_line(
4569 report::HumanStatus::Ok,
4570 format_args!(
4571 "Type-aware companion: available ({}, protocol {}, TypeScript {})",
4572 status.package_version.as_deref().unwrap_or("unknown"),
4573 status.protocol_version,
4574 status.backend_version.as_deref().unwrap_or("unknown"),
4575 )
4576 )
4577 );
4578 } else {
4579 crate::report::sink::outln!(
4580 "{}",
4581 report::human_status_line(
4582 report::HumanStatus::Inactive,
4583 "Type-aware companion: unavailable"
4584 )
4585 );
4586 if let Some(remediation) = status.remediation {
4587 crate::report::sink::outln!(
4588 "{}",
4589 report::human_status_line(
4590 report::HumanStatus::Warning,
4591 format_args!("Action: {remediation}")
4592 )
4593 );
4594 }
4595 }
4596 ExitCode::SUCCESS
4597 }
4598 _ => emit_error(
4599 "type-aware status supports human and json output",
4600 2,
4601 dispatch.output,
4602 ),
4603 }
4604 }
4605 }
4606}
4607
4608fn type_aware_status_output(
4609 root: &Path,
4610 status: fallow_api::TypeAwareStatus,
4611) -> fallow_output::TypeAwareStatusOutput {
4612 let companion_path = status.companion_path.as_deref().map(|path| {
4613 if let Ok(relative) = path.strip_prefix(root)
4614 && !relative.as_os_str().is_empty()
4615 {
4616 relative.to_string_lossy().replace('\\', "/")
4617 } else {
4618 path.file_name()
4619 .unwrap_or(path.as_os_str())
4620 .to_string_lossy()
4621 .into_owned()
4622 }
4623 });
4624 let remediation = status.remediation.map(|message| {
4625 let without_root = message.replace(root.to_string_lossy().as_ref(), ".");
4626 status.companion_path.as_deref().map_or_else(
4627 || without_root.clone(),
4628 |path| {
4629 without_root.replace(
4630 path.to_string_lossy().as_ref(),
4631 companion_path.as_deref().unwrap_or("fallow-type-aware"),
4632 )
4633 },
4634 )
4635 });
4636 fallow_output::TypeAwareStatusOutput {
4637 schema_version: fallow_types::envelope::SchemaVersion(
4638 fallow_output::TYPE_AWARE_STATUS_SCHEMA_VERSION,
4639 ),
4640 version: fallow_types::envelope::ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
4641 available: status.available,
4642 discovery_source: status.discovery_source.map(str::to_string),
4643 companion_path,
4644 package_version: status.package_version,
4645 protocol_version: status.protocol_version,
4646 backend_family: status.backend_family,
4647 backend_version: status.backend_version,
4648 remediation,
4649 }
4650}
4651
4652fn dispatch_check_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4654 let filters = check_issue_filters(&command);
4655 let Command::Check {
4656 include_dupes,
4657 trace,
4658 trace_file,
4659 trace_dependency,
4660 impact_closure,
4661 symbol_impact,
4662 top,
4663 file,
4664 finding_id,
4665 path,
4666 ..
4667 } = command
4668 else {
4669 unreachable!("check dispatcher only handles check commands");
4670 };
4671
4672 let finding_ids = match fallow_engine::dead_code::FindingIdFilter::parse(&finding_id) {
4673 Ok(filter) => filter,
4674 Err(message) => return emit_error(&format!("--finding-id: {message}"), 2, dispatch.output),
4675 };
4676
4677 let scope = match crate::scope_path::resolve_command_scope(dispatch.root, dispatch.output, path)
4678 {
4679 Ok(scope) => scope.map(|resolved| resolved.absolute),
4680 Err(code) => return code,
4681 };
4682
4683 dispatch_check(
4684 dispatch,
4685 &CheckDispatchArgs {
4686 filters,
4687 trace_opts: TraceOptions {
4688 trace_export: trace,
4689 trace_file,
4690 trace_dependency,
4691 impact_closure,
4692 symbol_impact,
4693 performance: dispatch.cli.performance,
4694 },
4695 include_dupes,
4696 type_aware: dispatch.cli.type_aware_override(),
4697 type_aware_project: dispatch.cli.type_aware_project.clone(),
4698 type_aware_require: dispatch.cli.type_aware_require,
4699 top,
4700 file,
4701 scope,
4702 finding_ids,
4703 surface: check::CheckSurface::DeadCode,
4704 },
4705 )
4706}
4707
4708fn dispatch_architecture_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4712 let Command::Architecture {
4713 cycles,
4714 boundaries,
4715 policy,
4716 file,
4717 finding_id,
4718 path,
4719 } = command
4720 else {
4721 unreachable!("architecture dispatcher only handles architecture commands");
4722 };
4723
4724 let finding_ids = match fallow_engine::dead_code::FindingIdFilter::parse(&finding_id) {
4725 Ok(filter) => filter,
4726 Err(message) => return emit_error(&format!("--finding-id: {message}"), 2, dispatch.output),
4727 };
4728
4729 let scope = match crate::scope_path::resolve_command_scope(dispatch.root, dispatch.output, path)
4730 {
4731 Ok(scope) => scope.map(|resolved| resolved.absolute),
4732 Err(code) => return code,
4733 };
4734
4735 dispatch_check(
4736 dispatch,
4737 &CheckDispatchArgs {
4738 filters: architecture_issue_filters(cycles, boundaries, policy),
4739 trace_opts: TraceOptions {
4740 trace_export: None,
4741 trace_file: None,
4742 trace_dependency: None,
4743 impact_closure: None,
4744 symbol_impact: None,
4745 performance: dispatch.cli.performance,
4746 },
4747 include_dupes: false,
4748 type_aware: dispatch.cli.type_aware_override(),
4749 type_aware_project: dispatch.cli.type_aware_project.clone(),
4750 type_aware_require: dispatch.cli.type_aware_require,
4751 top: None,
4752 file,
4753 scope,
4754 finding_ids,
4755 surface: check::CheckSurface::Architecture,
4756 },
4757 )
4758}
4759
4760fn architecture_issue_filters(cycles: bool, boundaries: bool, policy: bool) -> IssueFilters {
4763 let all = !(cycles || boundaries || policy);
4764 let mut filters = IssueFilters::default();
4765 for (flag, active) in [
4766 ("--circular-deps", cycles || all),
4767 ("--re-export-cycles", cycles || all),
4768 ("--package-cycles", cycles || all),
4769 ("--boundary-violations", boundaries || all),
4770 ("--policy-violations", policy || all),
4771 ] {
4772 enable_check_filter(&mut filters, flag, active);
4773 }
4774 filters
4775}
4776
4777fn check_issue_filters(command: &Command) -> IssueFilters {
4782 check_issue_filters_framework(command, &check_issue_filters_core(command))
4783}
4784
4785fn check_issue_filters_core(command: &Command) -> IssueFilters {
4788 let Command::Check {
4789 unused_files,
4790 unused_exports,
4791 unused_deps,
4792 unused_types,
4793 private_type_leaks,
4794 deprecated_exports_in_use,
4795 unused_enum_members,
4796 unused_class_members,
4797 unresolved_imports,
4798 unlisted_deps,
4799 duplicate_exports,
4800 circular_deps,
4801 re_export_cycles,
4802 package_cycles,
4803 boundary_violations,
4804 policy_violations,
4805 stale_suppressions,
4806 ..
4807 } = command
4808 else {
4809 unreachable!("check filter builder only handles check commands");
4810 };
4811
4812 let mut filters = IssueFilters::default();
4813 for (flag, active) in [
4814 ("--unused-files", *unused_files),
4815 ("--unused-exports", *unused_exports),
4816 ("--unused-deps", *unused_deps),
4817 ("--unused-types", *unused_types),
4818 ("--private-type-leaks", *private_type_leaks),
4819 ("--deprecated-exports-in-use", *deprecated_exports_in_use),
4820 ("--unused-enum-members", *unused_enum_members),
4821 ("--unused-class-members", *unused_class_members),
4822 ("--unresolved-imports", *unresolved_imports),
4823 ("--unlisted-deps", *unlisted_deps),
4824 ("--duplicate-exports", *duplicate_exports),
4825 ("--circular-deps", *circular_deps),
4826 ("--re-export-cycles", *re_export_cycles),
4827 ("--package-cycles", *package_cycles),
4828 ("--boundary-violations", *boundary_violations),
4829 ("--policy-violations", *policy_violations),
4830 ("--stale-suppressions", *stale_suppressions),
4831 ] {
4832 enable_check_filter(&mut filters, flag, active);
4833 }
4834 filters
4835}
4836
4837fn check_issue_filters_framework(command: &Command, base: &IssueFilters) -> IssueFilters {
4840 let Command::Check {
4841 unused_store_members,
4842 unprovided_injects,
4843 unrendered_components,
4844 unused_component_props,
4845 absent_component_props,
4846 unused_component_emits,
4847 unused_component_inputs,
4848 unused_component_outputs,
4849 unused_svelte_events,
4850 unused_server_actions,
4851 unused_load_data_keys,
4852 unused_catalog_entries,
4853 empty_catalog_groups,
4854 unresolved_catalog_references,
4855 unused_dependency_overrides,
4856 misconfigured_dependency_overrides,
4857 ..
4858 } = command
4859 else {
4860 unreachable!("check filter builder only handles check commands");
4861 };
4862
4863 let mut filters = base.clone();
4864 for (flag, active) in [
4865 ("--unused-store-members", *unused_store_members),
4866 ("--unprovided-injects", *unprovided_injects),
4867 ("--unrendered-components", *unrendered_components),
4868 ("--unused-component-props", *unused_component_props),
4869 ("--absent-component-props", *absent_component_props),
4870 ("--unused-component-emits", *unused_component_emits),
4871 ("--unused-component-inputs", *unused_component_inputs),
4872 ("--unused-component-outputs", *unused_component_outputs),
4873 ("--unused-svelte-events", *unused_svelte_events),
4874 ("--unused-server-actions", *unused_server_actions),
4875 ("--unused-load-data-keys", *unused_load_data_keys),
4876 ("--unused-catalog-entries", *unused_catalog_entries),
4877 ("--empty-catalog-groups", *empty_catalog_groups),
4878 (
4879 "--unresolved-catalog-references",
4880 *unresolved_catalog_references,
4881 ),
4882 (
4883 "--unused-dependency-overrides",
4884 *unused_dependency_overrides,
4885 ),
4886 (
4887 "--misconfigured-dependency-overrides",
4888 *misconfigured_dependency_overrides,
4889 ),
4890 ] {
4891 enable_check_filter(&mut filters, flag, active);
4892 }
4893 filters
4894}
4895
4896fn enable_check_filter(filters: &mut IssueFilters, flag: &str, active: bool) {
4897 if active {
4898 assert!(
4899 filters.enable_cli_filter_flag(flag),
4900 "check command uses unregistered dead-code filter flag {flag}"
4901 );
4902 }
4903}
4904
4905fn dispatch_inspect_command(
4906 dispatch: &DispatchContext<'_>,
4907 file: Option<String>,
4908 symbol: Option<String>,
4909 symbol_chain: bool,
4910 churn: bool,
4911) -> ExitCode {
4912 let target = match (file, symbol) {
4913 (Some(file), None) => inspect::InspectTarget::File { file },
4914 (None, Some(symbol)) => match selector::parse_file_symbol_selector(&symbol) {
4915 Some((file, export_name)) => inspect::InspectTarget::Symbol {
4916 file: file.to_string(),
4917 export_name: export_name.to_string(),
4918 },
4919 None => {
4920 return emit_error(
4921 "--symbol must be formatted as FILE:EXPORT",
4922 2,
4923 dispatch.output,
4924 );
4925 }
4926 },
4927 _ => {
4928 return emit_error(
4929 "inspect requires exactly one of --file or --symbol",
4930 2,
4931 dispatch.output,
4932 );
4933 }
4934 };
4935
4936 let churn_config = if churn {
4937 match load_config_for_analysis(
4938 dispatch.root,
4939 &dispatch.cli.config,
4940 ConfigLoadOptions {
4941 output: dispatch.output,
4942 no_cache: dispatch.cli.no_cache,
4943 threads: dispatch.threads,
4944 production_override: None,
4945 quiet: dispatch.quiet,
4946 allow_remote_extends: dispatch.cli.allow_remote_extends,
4947 },
4948 fallow_config::ProductionAnalysis::Health,
4949 ) {
4950 Ok(config) => Some(config),
4951 Err(code) => return code,
4952 }
4953 } else {
4954 None
4955 };
4956
4957 inspect::run_inspect(&inspect::InspectOptions {
4958 root: dispatch.root,
4959 config_path: dispatch.cli.config.as_ref(),
4960 output: dispatch.output,
4961 json_style: dispatch.json_style,
4962 no_cache: dispatch.cli.no_cache,
4963 no_production: dispatch.cli.no_production,
4964 max_file_size: dispatch.cli.max_file_size,
4965 threads: dispatch.threads,
4966 quiet: dispatch.quiet,
4967 production: dispatch.cli.production,
4968 workspace: dispatch.cli.workspace.as_ref(),
4969 target,
4970 churn_cache_dir: churn_config
4971 .as_ref()
4972 .map(|config| config.cache_dir.as_path()),
4973 symbol_chain,
4974 type_aware: dispatch.cli.type_aware_override(),
4975 type_aware_projects: &dispatch.cli.type_aware_project,
4976 type_aware_require: dispatch.cli.type_aware_require.map(Into::into),
4977 })
4978}
4979
4980#[derive(Clone, Copy)]
4982struct TraceChainFlags {
4983 callers: bool,
4984 callees: bool,
4985 depth: Option<u32>,
4986}
4987
4988fn dispatch_trace_dependency(
4989 dispatch: &DispatchContext<'_>,
4990 target: trace_dependency::TraceDependencyTarget,
4991) -> ExitCode {
4992 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
4993 Ok(production) => production,
4994 Err(code) => return code,
4995 };
4996 trace_dependency::run_trace_dependency(&trace_dependency::TraceDependencyOptions {
4997 root: dispatch.root,
4998 config_path: &dispatch.cli.config,
4999 output: dispatch.output,
5000 json_style: dispatch.json_style,
5001 no_cache: dispatch.cli.no_cache,
5002 threads: dispatch.threads,
5003 quiet: dispatch.quiet,
5004 allow_remote_extends: dispatch.cli.allow_remote_extends,
5005 production,
5006 workspace: dispatch.cli.workspace.as_deref(),
5007 changed_workspaces: dispatch.cli.changed_workspaces.as_deref(),
5008 target,
5009 })
5010}
5011
5012fn dispatch_trace_command(
5013 dispatch: &DispatchContext<'_>,
5014 symbol: Option<String>,
5015 path: &[String],
5016 eager_only: bool,
5017 chain: TraceChainFlags,
5018) -> ExitCode {
5019 let TraceChainFlags {
5020 callers,
5021 callees,
5022 depth,
5023 } = chain;
5024 if let [from, to] = path {
5025 return trace_path::run_trace_path(&trace_path::TracePathOptions {
5026 root: dispatch.root,
5027 config_path: &dispatch.cli.config,
5028 output: dispatch.output,
5029 json_style: dispatch.json_style,
5030 no_cache: dispatch.cli.no_cache,
5031 threads: dispatch.threads,
5032 quiet: dispatch.quiet,
5033 allow_remote_extends: dispatch.cli.allow_remote_extends,
5034 from,
5035 to,
5036 eager_only,
5037 });
5038 }
5039 let Some(symbol) = symbol else {
5040 return emit_error(
5041 "trace requires a FILE:SYMBOL target or --path <FROM> <TO>",
5042 2,
5043 dispatch.output,
5044 );
5045 };
5046 trace_chain::run_trace(&trace_chain::TraceChainOptions {
5047 root: dispatch.root,
5048 config_path: &dispatch.cli.config,
5049 output: dispatch.output,
5050 json_style: dispatch.json_style,
5051 no_cache: dispatch.cli.no_cache,
5052 threads: dispatch.threads,
5053 quiet: dispatch.quiet,
5054 allow_remote_extends: dispatch.cli.allow_remote_extends,
5055 target: symbol,
5056 callers,
5057 callees,
5058 depth: depth.unwrap_or(fallow_types::trace_chain::DEFAULT_TRACE_DEPTH),
5059 })
5060}
5061
5062fn dispatch_security_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
5063 let Command::Security {
5064 subcommand,
5065 runtime_coverage,
5066 min_invocations_hot,
5067 file,
5068 gate,
5069 surface,
5070 path,
5071 } = command
5072 else {
5073 unreachable!("security dispatcher only handles security commands");
5074 };
5075
5076 let scope = match crate::scope_path::resolve_command_scope(dispatch.root, dispatch.output, path)
5077 {
5078 Ok(scope) => scope.map(|resolved| resolved.absolute),
5079 Err(code) => return code,
5080 };
5081
5082 let gate = gate.map(security::SecurityGateArg::into_mode);
5083 let cli = dispatch.cli;
5084 let (output, _quiet, fail_on_issues) =
5085 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
5086 let derived_flags = SecurityDerivedFlagState {
5087 output,
5088 json_style: dispatch.json_style,
5089 ci: cli.ci,
5090 fail_on_issues,
5091 sarif_file: cli.sarif_file.as_deref(),
5092 summary: cli.summary,
5093 explain: cli.explain,
5094 runtime_coverage: runtime_coverage.as_deref(),
5095 min_invocations_hot,
5096 file: file.as_slice(),
5097 gate,
5098 surface,
5099 };
5100 if let Some(code) = try_run_security_survivors(subcommand.as_ref(), &derived_flags) {
5101 return code;
5102 }
5103
5104 let scoped_files = scoped_security_files(&file, subcommand.as_ref());
5105 run_security_blind_spots_or_default(
5106 dispatch,
5107 &SecurityRunInputs {
5108 scoped_files: &scoped_files,
5109 subcommand: &subcommand,
5110 runtime_coverage: runtime_coverage.as_deref(),
5111 min_invocations_hot,
5112 gate,
5113 surface,
5114 scope,
5115 },
5116 &derived_flags,
5117 )
5118}
5119
5120struct SecurityRunInputs<'a> {
5123 scoped_files: &'a [PathBuf],
5124 subcommand: &'a Option<SecuritySubcommand>,
5125 runtime_coverage: Option<&'a Path>,
5126 min_invocations_hot: u64,
5127 gate: Option<security::SecurityGateMode>,
5128 surface: bool,
5129 scope: Option<PathBuf>,
5130}
5131
5132fn run_security_blind_spots_or_default(
5134 dispatch: &DispatchContext<'_>,
5135 inputs: &SecurityRunInputs<'_>,
5136 derived_flags: &SecurityDerivedFlagState<'_>,
5137) -> ExitCode {
5138 let cli = dispatch.cli;
5139 let (output, quiet, fail_on_issues) =
5140 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
5141 let opts = security::SecurityOptions {
5142 root: dispatch.root,
5143 config_path: &cli.config,
5144 output,
5145 json_style: dispatch.json_style,
5146 no_cache: cli.no_cache,
5147 threads: dispatch.threads,
5148 quiet,
5149 allow_remote_extends: cli.allow_remote_extends,
5150 fail_on_issues,
5151 sarif_file: cli.sarif_file.as_deref(),
5152 summary: cli.summary,
5153 changed_since: cli.changed_since.as_deref(),
5154 use_shared_diff_index: true,
5155 workspace: cli.workspace.as_deref(),
5156 changed_workspaces: cli.changed_workspaces.as_deref(),
5157 file: inputs.scoped_files,
5158 surface: inputs.surface,
5159 scope: inputs.scope.clone(),
5160 gate: inputs.gate,
5161 runtime_coverage: inputs.runtime_coverage,
5162 min_invocations_hot: inputs.min_invocations_hot,
5163 explain: cli.explain,
5164 };
5165 if matches!(
5166 inputs.subcommand,
5167 Some(SecuritySubcommand::BlindSpots { .. })
5168 ) {
5169 if let Some(code) = validate_security_blind_spots_flags(derived_flags) {
5170 return code;
5171 }
5172 security::run_blind_spots(&opts)
5173 } else {
5174 security::run(&opts)
5175 }
5176}
5177
5178fn try_run_security_survivors(
5181 subcommand: Option<&SecuritySubcommand>,
5182 flags: &SecurityDerivedFlagState<'_>,
5183) -> Option<ExitCode> {
5184 let Some(SecuritySubcommand::Survivors {
5185 candidates,
5186 verdicts,
5187 require_verdict_for_each_candidate,
5188 }) = subcommand
5189 else {
5190 return None;
5191 };
5192 if let Some(code) = validate_security_survivors_flags(flags) {
5193 return Some(code);
5194 }
5195 Some(security::run_survivors(
5196 &security::SecuritySurvivorsOptions {
5197 output: flags.output,
5198 json_style: flags.json_style,
5199 candidates,
5200 verdicts,
5201 require_verdict_for_each_candidate: *require_verdict_for_each_candidate,
5202 },
5203 ))
5204}
5205
5206fn scoped_security_files(
5208 file: &[PathBuf],
5209 subcommand: Option<&SecuritySubcommand>,
5210) -> Vec<PathBuf> {
5211 let mut scoped_files = file.to_vec();
5212 if let Some(SecuritySubcommand::BlindSpots {
5213 file: blind_spot_files,
5214 }) = subcommand
5215 {
5216 scoped_files.extend(blind_spot_files.iter().cloned());
5217 }
5218 scoped_files
5219}
5220
5221struct SecurityDerivedFlagState<'a> {
5222 output: fallow_config::OutputFormat,
5223 json_style: json_style::JsonStyle,
5224 ci: bool,
5225 fail_on_issues: bool,
5226 sarif_file: Option<&'a Path>,
5227 summary: bool,
5228 explain: bool,
5229 runtime_coverage: Option<&'a Path>,
5230 min_invocations_hot: u64,
5231 file: &'a [PathBuf],
5232 gate: Option<security::SecurityGateMode>,
5233 surface: bool,
5234}
5235
5236fn validate_security_survivors_flags(flags: &SecurityDerivedFlagState<'_>) -> Option<ExitCode> {
5237 let flag = if flags.ci {
5238 Some("--ci")
5239 } else if flags.fail_on_issues {
5240 Some("--fail-on-issues")
5241 } else if flags.sarif_file.is_some() {
5242 Some("--sarif-file")
5243 } else if flags.summary {
5244 Some("--summary")
5245 } else if flags.explain {
5246 Some("--explain")
5247 } else if flags.runtime_coverage.is_some() {
5248 Some("--runtime-coverage")
5249 } else if flags.min_invocations_hot != DEFAULT_MIN_INVOCATIONS_HOT {
5250 Some("--min-invocations-hot")
5251 } else if !flags.file.is_empty() {
5252 Some("--file")
5253 } else if flags.gate.is_some() {
5254 Some("--gate")
5255 } else if flags.surface {
5256 Some("--surface")
5257 } else {
5258 None
5259 }?;
5260 Some(emit_error(
5261 &format!("{flag} is not valid with `fallow security survivors`."),
5262 2,
5263 flags.output,
5264 ))
5265}
5266
5267fn validate_security_blind_spots_flags(flags: &SecurityDerivedFlagState<'_>) -> Option<ExitCode> {
5268 let flag = if flags.ci {
5269 Some("--ci")
5270 } else if flags.fail_on_issues {
5271 Some("--fail-on-issues")
5272 } else if flags.sarif_file.is_some() {
5273 Some("--sarif-file")
5274 } else if flags.summary {
5275 Some("--summary")
5276 } else if flags.explain {
5277 Some("--explain")
5278 } else if flags.runtime_coverage.is_some() {
5279 Some("--runtime-coverage")
5280 } else if flags.min_invocations_hot != DEFAULT_MIN_INVOCATIONS_HOT {
5281 Some("--min-invocations-hot")
5282 } else if flags.gate.is_some() {
5283 Some("--gate")
5284 } else if flags.surface {
5285 Some("--surface")
5286 } else {
5287 None
5288 }?;
5289 Some(emit_error(
5290 &format!("{flag} is not valid with `fallow security blind-spots`."),
5291 2,
5292 flags.output,
5293 ))
5294}
5295
5296fn dispatch_dupes_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
5297 let Command::Dupes {
5298 mode,
5299 near,
5300 min_tokens,
5301 min_lines,
5302 min_occurrences,
5303 threshold,
5304 skip_local,
5305 ignore_symlinks,
5306 no_ignore_symlinks,
5307 cross_language,
5308 ignore_imports,
5309 no_ignore_imports,
5310 top,
5311 no_fragments,
5312 trace,
5313 path,
5314 } = command
5315 else {
5316 unreachable!("dupes dispatcher only handles dupes commands");
5317 };
5318
5319 let scope = match crate::scope_path::resolve_command_scope(dispatch.root, dispatch.output, path)
5320 {
5321 Ok(scope) => scope.map(|resolved| resolved.absolute),
5322 Err(code) => return code,
5323 };
5324
5325 dispatch_dupes(
5326 dispatch,
5327 &DupesDispatchArgs {
5328 overrides: DupesOverrides {
5329 mode,
5330 near,
5331 threshold,
5332 min_tokens,
5333 min_lines,
5334 min_occurrences,
5335 skip_local,
5336 ignore_symlinks: resolve_negatable_flag(ignore_symlinks, no_ignore_symlinks),
5337 cross_language,
5338 ignore_imports: resolve_negatable_flag(ignore_imports, no_ignore_imports),
5339 },
5340 top,
5341 no_fragments,
5342 trace,
5343 scope,
5344 },
5345 )
5346}
5347
5348fn dispatch_agent_command(dispatch: &DispatchContext<'_>, subcommand: AgentCli) -> ExitCode {
5349 run_agent_command(
5350 dispatch.root,
5351 dispatch.cli.root.is_some(),
5352 subcommand,
5353 dispatch.output,
5354 dispatch.json_style,
5355 )
5356}
5357
5358fn dispatch_init_command(command: Command, root: &Path, quiet: bool) -> ExitCode {
5359 let Command::Init {
5360 toml,
5361 agents,
5362 hooks,
5363 branch,
5364 decline,
5365 } = command
5366 else {
5367 unreachable!("init dispatcher only handles init commands");
5368 };
5369
5370 init::run_init(&init::InitOptions {
5371 root,
5372 use_toml: toml,
5373 agents,
5374 hooks,
5375 branch: branch.as_deref(),
5376 decline,
5377 quiet,
5378 })
5379}
5380
5381fn dispatch_fix_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
5382 let Command::Fix {
5383 dry_run,
5384 yes,
5385 no_create_config,
5386 path,
5387 } = command
5388 else {
5389 unreachable!("fix dispatcher only handles fix commands");
5390 };
5391
5392 let scope = match crate::scope_path::resolve_command_scope(
5393 dispatch.root,
5394 dispatch.output,
5395 path.clone(),
5396 ) {
5397 Ok(scope) => scope.map(|resolved| resolved.absolute),
5398 Err(code) => return code,
5399 };
5400
5401 dispatch_fix(
5402 dispatch,
5403 &FixDispatchArgs {
5404 dry_run: *dry_run,
5405 yes: *yes,
5406 no_create_config: *no_create_config,
5407 scope,
5408 },
5409 )
5410}
5411
5412fn dispatch_list_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
5413 match command {
5414 Command::Workspaces => dispatch_list(dispatch, &ListDispatchArgs::workspaces()),
5415 Command::List {
5416 entry_points,
5417 files,
5418 plugins,
5419 boundaries,
5420 workspaces,
5421 entry_weight,
5422 path,
5423 } => {
5424 let scope = match crate::scope_path::resolve_command_scope(
5425 dispatch.root,
5426 dispatch.output,
5427 path.clone(),
5428 ) {
5429 Ok(scope) => scope.map(|resolved| resolved.absolute),
5430 Err(code) => return code,
5431 };
5432 dispatch_list(
5433 dispatch,
5434 &ListDispatchArgs {
5435 entry_points: *entry_points,
5436 files: *files,
5437 plugins: *plugins,
5438 boundaries: *boundaries,
5439 workspaces: *workspaces,
5440 entry_weight: *entry_weight,
5441 scope,
5442 },
5443 )
5444 }
5445 _ => unreachable!("list dispatcher only handles list commands"),
5446 }
5447}
5448
5449fn dispatch_migrate_command(command: Command, root: &Path) -> ExitCode {
5450 let Command::Migrate {
5451 toml,
5452 jsonc,
5453 dry_run,
5454 from,
5455 } = command
5456 else {
5457 unreachable!("migrate dispatcher only handles migrate commands");
5458 };
5459
5460 migrate::run_migrate(root, toml, jsonc, dry_run, from.as_deref())
5461}
5462
5463fn dispatch_license_command(
5464 subcommand: LicenseCli,
5465 output: fallow_config::OutputFormat,
5466 json_style: json_style::JsonStyle,
5467) -> ExitCode {
5468 license::run(&map_license_subcommand(subcommand), output, json_style)
5469}
5470
5471fn dispatch_ci_template_command(subcommand: CiTemplateCli) -> ExitCode {
5472 match subcommand {
5473 CiTemplateCli::Gitlab { vendor, force } => {
5474 ci_template::run_gitlab_template(&ci_template::GitlabTemplateOptions {
5475 vendor_dir: vendor,
5476 force,
5477 })
5478 }
5479 }
5480}
5481
5482fn dispatch_coverage_command(dispatch: &DispatchContext<'_>, subcommand: &CoverageCli) -> ExitCode {
5483 let cli = dispatch.cli;
5484 coverage::run(
5485 map_coverage_subcommand(subcommand, cli.explain, cli.changed_since.as_deref()),
5486 &coverage::RunContext {
5487 root: dispatch.root,
5488 config_path: &cli.config,
5489 output: dispatch.output,
5490 json_style: dispatch.json_style,
5491 quiet: dispatch.quiet,
5492 no_cache: cli.no_cache,
5493 threads: dispatch.threads,
5494 explain: cli.explain,
5495 allow_remote_extends: cli.allow_remote_extends,
5496 },
5497 )
5498}
5499
5500fn dispatch_health_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
5501 let Command::Health {
5502 max_cyclomatic,
5503 max_cognitive,
5504 max_crap,
5505 top,
5506 sort,
5507 complexity,
5508 complexity_breakdown,
5509 file_scores,
5510 coverage_gaps,
5511 hotspots,
5512 ownership,
5513 ownership_emails,
5514 targets,
5515 type_coupling,
5516 css,
5517 effort,
5518 score,
5519 min_score,
5520 min_severity,
5521 report_only,
5522 since,
5523 min_commits,
5524 save_snapshot,
5525 trend,
5526 trend_from,
5527 coverage,
5528 coverage_root,
5529 runtime_coverage,
5530 min_invocations_hot,
5531 min_observation_volume,
5532 low_traffic_threshold,
5533 path,
5534 } = command
5535 else {
5536 unreachable!("health dispatcher only handles health commands");
5537 };
5538
5539 let scope = match crate::scope_path::resolve_command_scope(dispatch.root, dispatch.output, path)
5540 {
5541 Ok(scope) => scope.map(|resolved| resolved.absolute),
5542 Err(code) => return code,
5543 };
5544
5545 let ownership = ownership || ownership_emails.is_some();
5546 let hotspots = hotspots || ownership;
5547 let args = HealthDispatchArgs {
5548 max_cyclomatic,
5549 max_cognitive,
5550 max_crap,
5551 top,
5552 sort,
5553 complexity,
5554 complexity_breakdown,
5555 file_scores,
5556 coverage_gaps,
5557 hotspots,
5558 ownership,
5559 ownership_emails: ownership_emails.map(EmailModeArg::to_config),
5560 targets,
5561 type_coupling,
5562 css,
5563 effort,
5564 score,
5565 min_score,
5566 min_severity: min_severity.map(HealthSeverityCli::to_health_severity),
5567 report_only,
5568 since: since.as_deref(),
5569 min_commits,
5570 save_snapshot: save_snapshot.as_ref(),
5571 trend: trend || trend_from.is_some(),
5572 trend_from: trend_from.as_deref(),
5573 fail_on_stale_baseline: dispatch.cli.fail_on_stale_baseline,
5574 fail_on_parse_error: dispatch.cli.fail_on_parse_error,
5575 coverage: coverage.as_deref(),
5576 coverage_root: coverage_root.as_deref(),
5577 runtime_coverage: runtime_coverage.as_deref(),
5578 min_invocations_hot,
5579 min_observation_volume,
5580 low_traffic_threshold,
5581 scope,
5582 };
5583 dispatch_health(dispatch, &args)
5584}
5585
5586fn dispatch_setup_hooks_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
5587 let Command::SetupHooks {
5588 agent,
5589 dry_run,
5590 force,
5591 user,
5592 gitignore_claude,
5593 uninstall,
5594 } = command
5595 else {
5596 unreachable!("setup-hooks dispatcher only handles setup-hooks commands");
5597 };
5598
5599 eprintln!(
5600 "warning: `fallow setup-hooks` is deprecated and will be removed in the next major; use `fallow agent install` or `fallow hooks install --target agent`."
5601 );
5602 setup_hooks::run_setup_hooks(&setup_hooks::SetupHooksOptions {
5603 root: dispatch.root,
5604 agent: *agent,
5605 dry_run: *dry_run,
5606 force: *force,
5607 user: *user,
5608 home: None,
5609 gitignore_claude: *gitignore_claude,
5610 uninstall: *uninstall,
5611 })
5612}
5613
5614fn dispatch_audit_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
5615 let Command::Audit {
5616 production_dead_code,
5617 production_health,
5618 production_dupes,
5619 dead_code_baseline,
5620 health_baseline,
5621 dupes_baseline,
5622 max_crap,
5623 coverage,
5624 coverage_root,
5625 no_css,
5626 css_deep,
5627 no_css_deep,
5628 gate,
5629 runtime_coverage,
5630 min_invocations_hot,
5631 gate_marker,
5632 brief,
5633 max_decisions,
5634 walkthrough_guide,
5635 walkthrough_file,
5636 walkthrough,
5637 mark_viewed,
5638 show_cleared,
5639 show_deprioritized,
5640 path,
5641 } = command
5642 else {
5643 unreachable!("audit dispatcher only handles audit commands");
5644 };
5645
5646 let brief = brief || walkthrough_guide || walkthrough || walkthrough_file.is_some();
5649
5650 let scope = match crate::scope_path::resolve_command_scope(dispatch.root, dispatch.output, path)
5651 {
5652 Ok(scope) => scope.map(|resolved| resolved.absolute),
5653 Err(code) => return code,
5654 };
5655
5656 dispatch_audit(
5657 dispatch,
5658 &AuditDispatchArgs {
5659 production_dead_code,
5660 production_health,
5661 production_dupes,
5662 dead_code_baseline,
5663 health_baseline,
5664 dupes_baseline,
5665 max_crap,
5666 coverage,
5667 coverage_root,
5668 no_css,
5669 css_deep,
5670 no_css_deep,
5671 gate,
5672 runtime_coverage,
5673 min_invocations_hot,
5674 gate_marker,
5675 brief,
5676 max_decisions,
5677 walkthrough_guide,
5678 walkthrough_file,
5679 walkthrough,
5680 mark_viewed,
5681 show_cleared,
5682 show_deprioritized,
5683 scope,
5684 },
5685 )
5686}
5687
5688fn dispatch_audit_cache_command(
5689 dispatch: &DispatchContext<'_>,
5690 subcommand: &AuditCacheCli,
5691) -> ExitCode {
5692 match subcommand {
5693 AuditCacheCli::Remove { dry_run, yes } => {
5694 if !*dry_run && !*yes && !std::io::stdin().is_terminal() {
5695 return emit_error(
5696 "audit-cache remove requires --yes (or --force) in non-interactive environments. Use --dry-run to preview removal first, then pass --yes to confirm.",
5697 2,
5698 dispatch.output,
5699 );
5700 }
5701 match base_worktree::remove_reusable_audit_caches(dispatch.root, *dry_run) {
5702 Ok(report) => {
5703 let action = if *dry_run { "would remove" } else { "removed" };
5704 if matches!(dispatch.output, fallow_config::OutputFormat::Json) {
5705 let value = serde_json::json!({
5706 "kind": "audit-cache-remove",
5707 "schema_version": 1,
5708 "command": "audit-cache remove",
5709 "root": dispatch.root,
5710 "dry_run": report.dry_run,
5711 "found": report.found,
5712 "would_remove": report.found.saturating_sub(report.skipped),
5713 "removed": report.removed,
5714 "skipped": report.skipped,
5715 "complete": report.skipped == 0,
5716 });
5717 let output_code = report::emit_report_json(
5718 &value,
5719 "audit cache removal",
5720 dispatch.json_style,
5721 );
5722 if output_code != ExitCode::SUCCESS {
5723 return output_code;
5724 }
5725 } else if !dispatch.quiet {
5726 println!(
5727 "audit cache: {action} {}, skipped {} for {}",
5728 if *dry_run {
5729 report.found.saturating_sub(report.skipped)
5730 } else {
5731 report.removed
5732 },
5733 report.skipped,
5734 dispatch.root.display(),
5735 );
5736 }
5737 if report.skipped == 0 {
5738 ExitCode::SUCCESS
5739 } else {
5740 ExitCode::from(2)
5741 }
5742 }
5743 Err(error) => emit_error(
5744 &format!(
5745 "failed to remove audit caches for {}: {error}",
5746 dispatch.root.display()
5747 ),
5748 2,
5749 dispatch.output,
5750 ),
5751 }
5752 }
5753 AuditCacheCli::Prune {
5754 dry_run,
5755 max_age_days,
5756 } => audit_cache_prune::run_audit_cache_prune(&audit_cache_prune::AuditCachePruneOptions {
5757 root: dispatch.root,
5758 config_path: dispatch.cli.config.as_ref(),
5759 allow_remote_extends: dispatch.cli.allow_remote_extends,
5760 dry_run: *dry_run,
5761 max_age_days: *max_age_days,
5762 output: dispatch.output,
5763 json_style: dispatch.json_style,
5764 quiet: dispatch.quiet,
5765 }),
5766 }
5767}
5768
5769fn dispatch_baselines_command(
5770 dispatch: &DispatchContext<'_>,
5771 subcommand: &BaselinesCli,
5772) -> ExitCode {
5773 let BaselinesCli::Prune {
5774 check,
5775 dead_code_baseline,
5776 health_baseline,
5777 dupes_baseline,
5778 coverage,
5779 coverage_root,
5780 } = subcommand;
5781 let cli = dispatch.cli;
5782 let root = dispatch.root;
5783 let output = dispatch.output;
5784 if let Some(flag) = baselines_prune_narrowing_flag(cli) {
5785 return emit_error(
5786 &format!(
5787 "`fallow baselines prune` always analyzes the whole project, so it does not accept `{flag}`. A narrowed run would remove valid entries."
5788 ),
5789 2,
5790 output,
5791 );
5792 }
5793 if let Some(flag) = cli_global_baseline_flag(cli) {
5794 return emit_error(
5795 &format!(
5796 "`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."
5797 ),
5798 2,
5799 output,
5800 );
5801 }
5802 let config = match load_config(
5803 root,
5804 &cli.config,
5805 LoadConfigArgs {
5806 output,
5807 no_cache: cli.no_cache,
5808 threads: dispatch.threads,
5809 production: false,
5810 quiet: dispatch.quiet,
5811 allow_remote_extends: cli.allow_remote_extends,
5812 },
5813 ) {
5814 Ok(config) => config,
5815 Err(code) => return code,
5816 };
5817 let targets = baselines::BaselineTargets {
5818 dead_code: resolve_audit_baseline_path(
5819 root,
5820 dead_code_baseline.as_deref(),
5821 config.audit.dead_code_baseline.as_deref(),
5822 ),
5823 health: resolve_audit_baseline_path(
5824 root,
5825 health_baseline.as_deref(),
5826 config.audit.health_baseline.as_deref(),
5827 ),
5828 dupes: resolve_audit_baseline_path(
5829 root,
5830 dupes_baseline.as_deref(),
5831 config.audit.dupes_baseline.as_deref(),
5832 ),
5833 };
5834 if targets.dead_code.is_none() && targets.health.is_none() && targets.dupes.is_none() {
5835 return emit_error(
5836 "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",
5837 2,
5838 output,
5839 );
5840 }
5841 let production = match resolve_production_modes(cli, root, output, false, false, false) {
5842 Ok(production) => production,
5843 Err(code) => return code,
5844 };
5845 let coverage_inputs = match resolve_coverage_inputs(
5846 coverage.as_deref(),
5847 coverage_root.as_deref(),
5848 output,
5849 || Ok(config.health),
5850 ) {
5851 Ok(inputs) => inputs,
5852 Err(code) => return code,
5853 };
5854 baselines::run_baselines_prune(&baselines::BaselinesPruneOptions {
5855 root,
5856 config_path: &cli.config,
5857 output,
5858 json_style: dispatch.json_style,
5859 no_cache: cli.no_cache,
5860 threads: dispatch.threads,
5861 quiet: dispatch.quiet,
5862 allow_remote_extends: cli.allow_remote_extends,
5863 check: *check,
5864 targets,
5865 production,
5866 coverage: coverage_inputs.coverage.as_deref(),
5867 coverage_root: coverage_inputs.coverage_root.as_deref(),
5868 regression_opts: dispatch.regression_opts(true),
5869 })
5870}
5871
5872fn baselines_prune_narrowing_flag(cli: &Cli) -> Option<&'static str> {
5874 [
5875 (cli.changed_since.is_some(), "--changed-since"),
5876 (cli.workspace.is_some(), "--workspace"),
5877 (cli.changed_workspaces.is_some(), "--changed-workspaces"),
5878 (cli.diff_file.is_some(), "--diff-file"),
5879 (cli.diff_stdin, "--diff-stdin"),
5880 (
5881 std::env::var_os("FALLOW_DIFF_FILE").is_some_and(|value| !value.is_empty()),
5882 "FALLOW_DIFF_FILE",
5883 ),
5884 (cli.production, "--production"),
5885 ]
5886 .into_iter()
5887 .find_map(|(present, flag)| present.then_some(flag))
5888}
5889
5890fn dispatch_flags_command(
5891 dispatch: &DispatchContext<'_>,
5892 top: Option<usize>,
5893 retirement: Option<flags::RetirementArgs>,
5894) -> ExitCode {
5895 let cli = dispatch.cli;
5896 let root = dispatch.root;
5897 let output = dispatch.output;
5898 let quiet = dispatch.quiet;
5899 let threads = dispatch.threads;
5900 let production = match resolve_production_modes(cli, root, output, false, false, false) {
5901 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::DeadCode),
5902 Err(code) => return code,
5903 };
5904 flags::run_flags(&flags::FlagsOptions {
5905 root,
5906 config_path: &cli.config,
5907 output,
5908 json_style: dispatch.json_style,
5909 no_cache: cli.no_cache,
5910 threads,
5911 quiet,
5912 allow_remote_extends: cli.allow_remote_extends,
5913 production,
5914 workspace: cli.workspace.as_deref(),
5915 changed_workspaces: cli.changed_workspaces.as_deref(),
5916 changed_since: cli.changed_since.as_deref(),
5917 explain: cli.explain,
5918 top,
5919 retirement,
5920 regression: dispatch.regression_opts(false),
5921 regression_flag: first_regression_flag(cli),
5922 })
5923}
5924
5925fn first_regression_flag(cli: &Cli) -> Option<&'static str> {
5927 [
5928 (cli.fail_on_regression, "--fail-on-regression"),
5929 (cli.regression_baseline.is_some(), "--regression-baseline"),
5930 (
5931 cli.save_regression_baseline.is_some(),
5932 "--save-regression-baseline",
5933 ),
5934 (cli.tolerance != "0", "--tolerance"),
5935 ]
5936 .into_iter()
5937 .find_map(|(used, flag)| used.then_some(flag))
5938}
5939
5940fn dispatch_suppressions_command(
5941 dispatch: &DispatchContext<'_>,
5942 file: &[std::path::PathBuf],
5943) -> ExitCode {
5944 let cli = dispatch.cli;
5945 let root = dispatch.root;
5946 let output = dispatch.output;
5947 let production = match resolve_production_modes(cli, root, output, false, false, false) {
5948 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::DeadCode),
5949 Err(code) => return code,
5950 };
5951 suppressions::run_suppressions(&suppressions::SuppressionsOptions {
5952 root,
5953 config_path: &cli.config,
5954 output,
5955 json_style: dispatch.json_style,
5956 no_cache: cli.no_cache,
5957 threads: dispatch.threads,
5958 quiet: dispatch.quiet,
5959 allow_remote_extends: cli.allow_remote_extends,
5960 production,
5961 workspace: cli.workspace.as_deref(),
5962 changed_workspaces: cli.changed_workspaces.as_deref(),
5963 changed_since: cli.changed_since.as_deref(),
5964 file,
5965 })
5966}
5967
5968fn dispatch_guard_command(dispatch: &DispatchContext<'_>, files: &[String]) -> ExitCode {
5969 guard::run_guard(&guard::GuardOptions {
5970 root: dispatch.root,
5971 config_path: &dispatch.cli.config,
5972 output: dispatch.output,
5973 json_style: dispatch.json_style,
5974 quiet: dispatch.quiet,
5975 allow_remote_extends: dispatch.cli.allow_remote_extends,
5976 files,
5977 })
5978}
5979
5980fn dispatch_rule_pack_command(dispatch: &DispatchContext<'_>, subcommand: RulePackCli) -> ExitCode {
5981 let ctx = rule_pack::RulePackContext {
5982 root: dispatch.root,
5983 config_path: &dispatch.cli.config,
5984 output: dispatch.output,
5985 json_style: dispatch.json_style,
5986 quiet: dispatch.quiet,
5987 no_cache: dispatch.cli.no_cache,
5988 threads: Some(dispatch.threads),
5989 allow_remote_extends: dispatch.cli.allow_remote_extends,
5990 };
5991 rule_pack::run(&map_rule_pack_subcommand(subcommand), &ctx)
5992}
5993
5994fn map_rule_pack_subcommand(subcommand: RulePackCli) -> rule_pack::RulePackSubcommand {
5995 match subcommand {
5996 RulePackCli::Init {
5997 name,
5998 template,
5999 dir,
6000 no_config,
6001 } => rule_pack::RulePackSubcommand::Init(rule_pack::InitArgs {
6002 name,
6003 template,
6004 dir,
6005 no_config,
6006 }),
6007 RulePackCli::List => rule_pack::RulePackSubcommand::List,
6008 RulePackCli::Test { pack } => {
6009 rule_pack::RulePackSubcommand::Test(rule_pack::TestArgs { pack })
6010 }
6011 RulePackCli::Schema => rule_pack::RulePackSubcommand::Schema,
6012 }
6013}
6014
6015fn map_license_subcommand(sub: LicenseCli) -> license::LicenseSubcommand {
6016 match sub {
6017 LicenseCli::Activate {
6018 jwt,
6019 from_file,
6020 stdin,
6021 trial,
6022 email,
6023 } => license::LicenseSubcommand::Activate(license::ActivateArgs {
6024 raw_jwt: jwt,
6025 from_file,
6026 from_stdin: stdin,
6027 trial,
6028 email,
6029 }),
6030 LicenseCli::Status => license::LicenseSubcommand::Status,
6031 LicenseCli::Refresh { api_key } => {
6032 license::LicenseSubcommand::Refresh(license::RefreshArgs { api_key })
6033 }
6034 LicenseCli::Deactivate => license::LicenseSubcommand::Deactivate,
6035 }
6036}
6037
6038fn map_telemetry_subcommand(sub: TelemetryCli) -> telemetry::TelemetryCommand {
6039 match sub {
6040 TelemetryCli::Status => telemetry::TelemetryCommand::Status,
6041 TelemetryCli::Enable => telemetry::TelemetryCommand::Enable,
6042 TelemetryCli::Disable => telemetry::TelemetryCommand::Disable,
6043 TelemetryCli::Inspect { example } => telemetry::TelemetryCommand::Inspect { example },
6044 }
6045}
6046
6047fn map_ci_subcommand(sub: CiCli) -> ci::CiCommand {
6048 match sub {
6049 command @ CiCli::PlanPrComment { .. } => map_ci_plan_pr_comment(command),
6050 command @ CiCli::PostPrComment { .. } => map_ci_post_pr_comment(command),
6051 command @ CiCli::PostReview { .. } => map_ci_post_review(command),
6052 command @ CiCli::PostCheckRun { .. } => map_ci_post_check_run(command),
6053 command @ CiCli::ReconcileReview { .. } => map_ci_reconcile_review(command),
6054 }
6055}
6056
6057fn map_ci_plan_pr_comment(command: CiCli) -> ci::CiCommand {
6058 let CiCli::PlanPrComment {
6059 body,
6060 marker_id,
6061 clean,
6062 existing_comment_id,
6063 existing_body,
6064 } = command
6065 else {
6066 unreachable!("ci plan-pr-comment mapper called with different variant");
6067 };
6068
6069 ci::CiCommand::PlanPrComment {
6070 body,
6071 marker_id,
6072 clean,
6073 existing_comment_id,
6074 existing_body,
6075 }
6076}
6077
6078fn map_ci_post_pr_comment(command: CiCli) -> ci::CiCommand {
6079 let CiCli::PostPrComment {
6080 provider,
6081 pr,
6082 mr,
6083 body,
6084 envelope,
6085 marker_id,
6086 clean,
6087 repo,
6088 project_id,
6089 api_url,
6090 dry_run,
6091 } = command
6092 else {
6093 unreachable!("ci post-pr-comment mapper called with different variant");
6094 };
6095
6096 ci::CiCommand::PostPrComment {
6097 provider: map_ci_provider(provider),
6098 target: pr.or(mr),
6099 body,
6100 envelope,
6101 marker_id,
6102 clean,
6103 repo,
6104 project_id,
6105 api_url,
6106 dry_run,
6107 }
6108}
6109
6110fn map_ci_post_review(command: CiCli) -> ci::CiCommand {
6111 let CiCli::PostReview {
6112 provider,
6113 pr,
6114 mr,
6115 envelope,
6116 repo,
6117 project_id,
6118 api_url,
6119 dry_run,
6120 } = command
6121 else {
6122 unreachable!("ci post-review mapper called with different variant");
6123 };
6124
6125 ci::CiCommand::PostReview {
6126 provider: map_ci_provider(provider),
6127 target: pr.or(mr),
6128 envelope,
6129 repo,
6130 project_id,
6131 api_url,
6132 dry_run,
6133 }
6134}
6135
6136fn map_ci_post_check_run(command: CiCli) -> ci::CiCommand {
6137 let CiCli::PostCheckRun {
6138 provider,
6139 decision,
6140 repo,
6141 head_sha,
6142 api_url,
6143 split_gates,
6144 dry_run,
6145 } = command
6146 else {
6147 unreachable!("ci post-check-run mapper called with different variant");
6148 };
6149
6150 ci::CiCommand::PostCheckRun {
6151 provider: map_ci_provider(provider),
6152 decision,
6153 repo,
6154 head_sha,
6155 api_url,
6156 split_gates,
6157 dry_run,
6158 }
6159}
6160
6161fn map_ci_reconcile_review(command: CiCli) -> ci::CiCommand {
6162 let CiCli::ReconcileReview {
6163 provider,
6164 pr,
6165 mr,
6166 envelope,
6167 repo,
6168 project_id,
6169 api_url,
6170 dry_run,
6171 } = command
6172 else {
6173 unreachable!("ci reconcile-review mapper called with different variant");
6174 };
6175
6176 ci::CiCommand::ReconcileReview {
6177 provider: map_ci_provider(provider),
6178 target: pr.or(mr),
6179 envelope,
6180 repo,
6181 project_id,
6182 api_url,
6183 dry_run,
6184 }
6185}
6186
6187fn map_ci_provider(provider: CiProviderArg) -> ci::CiProvider {
6188 match provider {
6189 CiProviderArg::Github => ci::CiProvider::Github,
6190 CiProviderArg::Gitlab => ci::CiProvider::Gitlab,
6191 }
6192}
6193
6194fn map_coverage_subcommand(
6195 sub: &CoverageCli,
6196 explain: bool,
6197 base: Option<&str>,
6198) -> coverage::CoverageSubcommand {
6199 match sub {
6200 CoverageCli::Setup {
6201 yes,
6202 non_interactive,
6203 json,
6204 } => map_coverage_setup(*yes, *non_interactive, *json, explain),
6205 CoverageCli::Analyze { .. } => map_coverage_analyze(sub),
6206 CoverageCli::UploadInventory { .. } => map_coverage_upload_inventory(sub),
6207 CoverageCli::UploadSourceMaps { .. } => map_coverage_upload_source_maps(sub),
6208 CoverageCli::UploadStaticFindings { .. } => map_coverage_upload_static_findings(sub),
6209 CoverageCli::ReviewPacket {
6210 api_key,
6211 api_endpoint,
6212 repo,
6213 project_id,
6214 coverage_period,
6215 commit_sha,
6216 files,
6217 functions,
6218 } => coverage::CoverageSubcommand::ReviewPacket(coverage::ReviewPacketArgs {
6219 api_key: api_key.clone(),
6220 api_endpoint: api_endpoint.clone(),
6221 repo: repo.clone(),
6222 project_id: project_id.clone(),
6223 coverage_period: *coverage_period,
6224 commit_sha: commit_sha.clone(),
6225 files: files.clone(),
6226 functions: functions.clone(),
6227 base: base.map(str::to_owned),
6228 }),
6229 CoverageCli::DeploymentChanges {
6230 api_key,
6231 api_endpoint,
6232 repo,
6233 sha,
6234 change,
6235 limit,
6236 cursor,
6237 } => coverage::CoverageSubcommand::DeploymentChanges(coverage::DeploymentChangesArgs {
6238 api_key: api_key.clone(),
6239 api_endpoint: api_endpoint.clone(),
6240 repo: repo.clone(),
6241 sha: sha.clone(),
6242 base: base.map(str::to_owned),
6243 change: change.clone(),
6244 limit: *limit,
6245 cursor: cursor.clone(),
6246 }),
6247 }
6248}
6249
6250fn map_coverage_setup(
6251 yes: bool,
6252 non_interactive: bool,
6253 json: bool,
6254 explain: bool,
6255) -> coverage::CoverageSubcommand {
6256 coverage::CoverageSubcommand::Setup(coverage::SetupArgs {
6257 yes,
6258 non_interactive: non_interactive || json,
6259 json,
6260 explain,
6261 })
6262}
6263
6264fn map_coverage_analyze(sub: &CoverageCli) -> coverage::CoverageSubcommand {
6265 let CoverageCli::Analyze {
6266 runtime_coverage,
6267 cloud,
6268 api_key,
6269 api_endpoint,
6270 repo,
6271 project_id,
6272 coverage_period,
6273 environment,
6274 commit_sha,
6275 production,
6276 min_invocations_hot,
6277 min_observation_volume,
6278 low_traffic_threshold,
6279 top,
6280 blast_radius,
6281 importance,
6282 debug_unmatched,
6283 } = sub
6284 else {
6285 unreachable!("coverage analyze mapper called with non-analyze variant");
6286 };
6287 coverage::CoverageSubcommand::Analyze(coverage::AnalyzeArgs {
6288 runtime_coverage: runtime_coverage.clone(),
6289 cloud: *cloud,
6290 api_key: api_key.clone(),
6291 api_endpoint: api_endpoint.clone(),
6292 repo: repo.clone(),
6293 project_id: project_id.clone(),
6294 coverage_period: *coverage_period,
6295 environment: environment.clone(),
6296 commit_sha: commit_sha.clone(),
6297 production: *production,
6298 min_invocations_hot: *min_invocations_hot,
6299 min_observation_volume: *min_observation_volume,
6300 low_traffic_threshold: *low_traffic_threshold,
6301 top: *top,
6302 blast_radius: *blast_radius,
6303 importance: *importance,
6304 debug_unmatched: *debug_unmatched,
6305 })
6306}
6307
6308fn map_coverage_upload_inventory(sub: &CoverageCli) -> coverage::CoverageSubcommand {
6309 let CoverageCli::UploadInventory {
6310 api_key,
6311 api_endpoint,
6312 project_id,
6313 git_sha,
6314 allow_dirty,
6315 exclude_paths,
6316 path_prefix,
6317 dry_run,
6318 with_callers,
6319 ignore_upload_errors,
6320 } = sub
6321 else {
6322 unreachable!("coverage inventory mapper called with non-inventory variant");
6323 };
6324 coverage::CoverageSubcommand::UploadInventory(coverage::UploadInventoryArgs {
6325 api_key: api_key.clone(),
6326 api_endpoint: api_endpoint.clone(),
6327 project_id: project_id.clone(),
6328 git_sha: git_sha.clone(),
6329 allow_dirty: *allow_dirty,
6330 exclude_paths: exclude_paths.clone(),
6331 path_prefix: path_prefix.clone(),
6332 dry_run: *dry_run,
6333 with_callers: *with_callers,
6334 ignore_upload_errors: *ignore_upload_errors,
6335 })
6336}
6337
6338fn map_coverage_upload_source_maps(sub: &CoverageCli) -> coverage::CoverageSubcommand {
6339 let CoverageCli::UploadSourceMaps {
6340 dir,
6341 include,
6342 exclude,
6343 repo,
6344 git_sha,
6345 endpoint,
6346 strip_path,
6347 dry_run,
6348 concurrency,
6349 fail_fast,
6350 } = sub
6351 else {
6352 unreachable!("coverage source-map mapper called with non-source-map variant");
6353 };
6354 coverage::CoverageSubcommand::UploadSourceMaps(coverage::UploadSourceMapsArgs {
6355 dir: dir.clone(),
6356 include: include.clone(),
6357 exclude: exclude.clone(),
6358 repo: repo.clone(),
6359 git_sha: git_sha.clone(),
6360 endpoint: endpoint.clone(),
6361 strip_path: *strip_path,
6362 dry_run: *dry_run,
6363 concurrency: *concurrency,
6364 fail_fast: *fail_fast,
6365 })
6366}
6367
6368fn map_coverage_upload_static_findings(sub: &CoverageCli) -> coverage::CoverageSubcommand {
6369 let CoverageCli::UploadStaticFindings {
6370 api_key,
6371 api_endpoint,
6372 project_id,
6373 git_sha,
6374 allow_dirty,
6375 dry_run,
6376 ignore_upload_errors,
6377 } = sub
6378 else {
6379 unreachable!("coverage static-findings mapper called with non-static variant");
6380 };
6381 coverage::CoverageSubcommand::UploadStaticFindings(coverage::UploadStaticFindingsArgs {
6382 api_key: api_key.clone(),
6383 api_endpoint: api_endpoint.clone(),
6384 project_id: project_id.clone(),
6385 git_sha: git_sha.clone(),
6386 allow_dirty: *allow_dirty,
6387 dry_run: *dry_run,
6388 ignore_upload_errors: *ignore_upload_errors,
6389 })
6390}
6391
6392struct CheckDispatchArgs {
6393 filters: IssueFilters,
6394 trace_opts: TraceOptions,
6395 include_dupes: bool,
6396 type_aware: Option<bool>,
6397 type_aware_project: Vec<std::path::PathBuf>,
6398 type_aware_require: Option<TypeAwareRequireArg>,
6399 top: Option<usize>,
6400 file: Vec<std::path::PathBuf>,
6401 scope: Option<std::path::PathBuf>,
6402 finding_ids: Option<fallow_engine::dead_code::FindingIdFilter>,
6403 surface: check::CheckSurface,
6404}
6405
6406#[derive(Clone)]
6407struct ListDispatchArgs {
6408 entry_points: bool,
6409 files: bool,
6410 plugins: bool,
6411 boundaries: bool,
6412 workspaces: bool,
6413 entry_weight: bool,
6414 scope: Option<std::path::PathBuf>,
6415}
6416
6417impl ListDispatchArgs {
6418 fn workspaces() -> Self {
6419 Self {
6420 entry_points: false,
6421 files: false,
6422 plugins: false,
6423 boundaries: false,
6424 workspaces: true,
6425 entry_weight: false,
6426 scope: None,
6427 }
6428 }
6429}
6430
6431fn dispatch_viz(
6432 dispatch: &DispatchContext<'_>,
6433 output_path: Option<&std::path::Path>,
6434 no_open: bool,
6435 format: viz::VizFormat,
6436) -> ExitCode {
6437 let cli = dispatch.cli;
6438 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
6439 Ok(production) => production,
6440 Err(code) => return code,
6441 };
6442 viz::run_viz(&viz::VizOptions {
6443 root: dispatch.root,
6444 config_path: &cli.config,
6445 no_cache: cli.no_cache,
6446 threads: dispatch.threads,
6447 quiet: dispatch.quiet,
6448 production,
6449 allow_remote_extends: cli.allow_remote_extends,
6450 output_path,
6451 no_open,
6452 format,
6453 })
6454}
6455
6456fn dispatch_watch(dispatch: &DispatchContext<'_>, no_clear: bool) -> ExitCode {
6457 let cli = dispatch.cli;
6458 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
6459 Ok(production) => production,
6460 Err(code) => return code,
6461 };
6462 watch::run_watch(&watch::WatchOptions {
6463 root: dispatch.root,
6464 config_path: &cli.config,
6465 output: dispatch.output,
6466 json_style: dispatch.json_style,
6467 no_cache: cli.no_cache,
6468 threads: dispatch.threads,
6469 quiet: dispatch.quiet,
6470 allow_remote_extends: cli.allow_remote_extends,
6471 production,
6472 clear_screen: !no_clear,
6473 explain: cli.explain,
6474 include_entry_exports: cli.include_entry_exports,
6475 show_cascade: cli.show_cascade,
6476 type_aware: cli.type_aware_override(),
6477 type_aware_projects: &cli.type_aware_project,
6478 type_aware_require: cli.type_aware_require.map(Into::into),
6479 })
6480}
6481
6482struct FixDispatchArgs {
6483 dry_run: bool,
6484 yes: bool,
6485 no_create_config: bool,
6486 scope: Option<std::path::PathBuf>,
6487}
6488
6489fn dispatch_fix(dispatch: &DispatchContext<'_>, args: &FixDispatchArgs) -> ExitCode {
6490 let cli = dispatch.cli;
6491 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
6492 Ok(production) => production,
6493 Err(code) => return code,
6494 };
6495 fix::run_fix(&fix::FixOptions {
6496 root: dispatch.root,
6497 config_path: &cli.config,
6498 output: dispatch.output,
6499 json_style: dispatch.json_style,
6500 no_cache: cli.no_cache,
6501 threads: dispatch.threads,
6502 quiet: dispatch.quiet,
6503 emit_output: true,
6504 allow_remote_extends: cli.allow_remote_extends,
6505 dry_run: args.dry_run,
6506 yes: args.yes,
6507 production,
6508 no_create_config: args.no_create_config,
6509 type_aware: cli.type_aware_override(),
6510 type_aware_projects: &cli.type_aware_project,
6511 type_aware_require: cli.type_aware_require.map(Into::into),
6512 scope: args.scope.clone(),
6513 show_cascade: cli.show_cascade,
6514 })
6515}
6516
6517fn dispatch_list(dispatch: &DispatchContext<'_>, args: &ListDispatchArgs) -> ExitCode {
6518 let cli = dispatch.cli;
6519 let (save_regression_file, save_to_config) = regression_save_targets(cli);
6520 let entry_weight_gate = if args.entry_weight {
6523 let tolerance = match regression::Tolerance::parse(&cli.tolerance) {
6524 Ok(tolerance) => tolerance,
6525 Err(message) => {
6526 return emit_error(
6527 &format!("invalid --tolerance: {message}"),
6528 2,
6529 dispatch.output,
6530 );
6531 }
6532 };
6533 Some(regression::EntryWeightGate {
6534 fail_on_regression: cli.fail_on_regression,
6535 tolerance,
6536 baseline_file: cli.regression_baseline.as_deref(),
6537 save_file: save_regression_file.as_deref(),
6538 save_to_config,
6539 })
6540 } else {
6541 None
6542 };
6543 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
6544 Ok(production) => production,
6545 Err(code) => return code,
6546 };
6547 list::run_list(&ListOptions {
6548 root: dispatch.root,
6549 config_path: &cli.config,
6550 output: dispatch.output,
6551 json_style: dispatch.json_style,
6552 threads: dispatch.threads,
6553 no_cache: cli.no_cache,
6554 entry_points: args.entry_points,
6555 files: args.files,
6556 plugins: args.plugins,
6557 boundaries: args.boundaries,
6558 workspaces: args.workspaces,
6559 entry_weight: args.entry_weight,
6560 entry_weight_gate,
6561 production,
6562 allow_remote_extends: cli.allow_remote_extends,
6563 scope: args.scope.clone(),
6564 })
6565}
6566
6567fn dispatch_check(dispatch: &DispatchContext<'_>, args: &CheckDispatchArgs) -> ExitCode {
6568 let cli = dispatch.cli;
6569 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
6570 Ok(production) => production,
6571 Err(code) => return code,
6572 };
6573 if let Some(code) = validate_type_aware_check_options(dispatch, args) {
6574 return code;
6575 }
6576 let growth_targets = baseline_growth::targets(&[(
6577 cli.baseline.as_deref(),
6578 fallow_engine::baseline::BaselineKind::DeadCode,
6579 )]);
6580 baseline_growth::run_gated(
6581 &dispatch.growth_ctx(baseline_growth::GrowthOwner::DeadCode),
6582 &growth_targets,
6583 || dispatch_check_run(dispatch, args, production),
6584 )
6585}
6586
6587fn dispatch_check_run(
6588 dispatch: &DispatchContext<'_>,
6589 args: &CheckDispatchArgs,
6590 production: bool,
6591) -> ExitCode {
6592 let cli = dispatch.cli;
6593 let (output, quiet, fail_on_issues) =
6594 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
6595 check::run_check(&CheckOptions {
6596 root: dispatch.root,
6597 config_path: &cli.config,
6598 output,
6599 json_style: dispatch.json_style,
6600 no_cache: cli.no_cache,
6601 threads: dispatch.threads,
6602 quiet,
6603 allow_remote_extends: cli.allow_remote_extends,
6604 fail_on_issues,
6605 filters: &args.filters,
6606 changed_since: cli.changed_since.as_deref(),
6607 change_scope_owner: fallow_engine::change_scope::ChangeScopeOwner::Run,
6608 no_package_baselines: cli.no_package_baselines,
6609 diff_index: None,
6610 use_shared_diff_index: true,
6611 baseline: cli.baseline.as_deref(),
6612 baseline_flag: "--baseline",
6613 bare_run: false,
6614 save_baseline: cli.save_baseline.as_deref(),
6615 fail_on_stale_baseline: cli.fail_on_stale_baseline,
6616 sarif_file: cli.sarif_file.as_deref(),
6617 production,
6618 production_override: Some(production),
6619 workspace: cli.workspace.as_deref(),
6620 changed_workspaces: cli.changed_workspaces.as_deref(),
6621 group_by: cli.group_by,
6622 include_dupes: args.include_dupes,
6623 type_aware: args.type_aware,
6624 type_aware_config_override: None,
6625 type_aware_projects: &args.type_aware_project,
6626 type_aware_require: args.type_aware_require.map(Into::into),
6627 trace_opts: &args.trace_opts,
6628 explain: cli.explain,
6629 top: args.top,
6630 file: &args.file,
6631 scope: args.scope.clone(),
6632 finding_ids: args.finding_ids.as_ref(),
6633 include_entry_exports: cli.include_entry_exports,
6634 show_cascade: cli.show_cascade,
6635 fail_on_parse_error: cli.fail_on_parse_error,
6636 summary: cli.summary,
6637 regression_opts: dispatch.regression_opts(
6638 cli.changed_since.is_some()
6639 || cli.workspace.is_some()
6640 || cli.changed_workspaces.is_some()
6641 || !args.file.is_empty()
6642 || args.scope.is_some(),
6643 ),
6644 retain_modules_for_health: false,
6645 defer_performance: true,
6646 analysis_snapshot: fallow_config::AnalysisSnapshot::Current,
6647 explain_skipped: cli.explain_skipped,
6648 surface: args.surface,
6649 })
6650}
6651
6652fn validate_type_aware_check_options(
6653 dispatch: &DispatchContext<'_>,
6654 args: &CheckDispatchArgs,
6655) -> Option<ExitCode> {
6656 let output = dispatch.output;
6657 if !args.type_aware_project.is_empty() && args.type_aware != Some(true) {
6658 return Some(emit_error(
6659 "--type-aware-project requires --type-aware",
6660 2,
6661 output,
6662 ));
6663 }
6664 if args.type_aware_require.is_some() && args.type_aware != Some(true) {
6665 return Some(emit_error(
6666 "--type-aware-require requires --type-aware",
6667 2,
6668 output,
6669 ));
6670 }
6671 if args.trace_opts.symbol_impact.is_some() && args.type_aware != Some(true) {
6672 return Some(emit_error(
6673 "--symbol-impact requires --type-aware",
6674 2,
6675 output,
6676 ));
6677 }
6678 let focused_output = args.trace_opts.trace_export.is_some()
6679 || args.trace_opts.trace_file.is_some()
6680 || args.trace_opts.trace_dependency.is_some()
6681 || args.trace_opts.impact_closure.is_some()
6682 || args.trace_opts.symbol_impact.is_some();
6683 if focused_output
6684 && !matches!(
6685 output,
6686 fallow_config::OutputFormat::Human | fallow_config::OutputFormat::Json
6687 )
6688 {
6689 return Some(emit_error(
6690 "focused trace and impact queries support human and JSON output",
6691 2,
6692 output,
6693 ));
6694 }
6695 if args.type_aware == Some(true)
6696 && !matches!(
6697 output,
6698 fallow_config::OutputFormat::Human
6699 | fallow_config::OutputFormat::Json
6700 | fallow_config::OutputFormat::Sarif
6701 | fallow_config::OutputFormat::Compact
6702 | fallow_config::OutputFormat::Markdown
6703 | fallow_config::OutputFormat::CodeClimate
6704 | fallow_config::OutputFormat::PrCommentGithub
6705 | fallow_config::OutputFormat::PrCommentGitlab
6706 | fallow_config::OutputFormat::ReviewGithub
6707 | fallow_config::OutputFormat::ReviewGitlab
6708 )
6709 {
6710 return Some(emit_error(
6711 "--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",
6712 2,
6713 output,
6714 ));
6715 }
6716 None
6717}
6718
6719const fn resolve_negatable_flag(opt_in: bool, opt_out: bool) -> Option<bool> {
6727 if opt_out {
6728 Some(false)
6729 } else if opt_in {
6730 Some(true)
6731 } else {
6732 None
6733 }
6734}
6735
6736struct DupesDispatchArgs {
6737 overrides: DupesOverrides,
6738 top: Option<usize>,
6739 no_fragments: bool,
6740 trace: Option<String>,
6741 scope: Option<std::path::PathBuf>,
6742}
6743
6744fn dispatch_dupes(dispatch: &DispatchContext<'_>, args: &DupesDispatchArgs) -> ExitCode {
6745 let cli = dispatch.cli;
6746 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::Dupes) {
6747 Ok(production) => production,
6748 Err(code) => return code,
6749 };
6750 let growth_targets = baseline_growth::targets(&[(
6751 cli.baseline.as_deref(),
6752 fallow_engine::baseline::BaselineKind::Dupes,
6753 )]);
6754 baseline_growth::run_gated(
6755 &dispatch.growth_ctx(baseline_growth::GrowthOwner::Dupes),
6756 &growth_targets,
6757 || dispatch_dupes_run(dispatch, args, production),
6758 )
6759}
6760
6761fn dispatch_dupes_run(
6762 dispatch: &DispatchContext<'_>,
6763 args: &DupesDispatchArgs,
6764 production: bool,
6765) -> ExitCode {
6766 let cli = dispatch.cli;
6767 let (output, quiet) = (dispatch.output, dispatch.quiet);
6768 dupes::run_dupes(&DupesOptions {
6769 root: dispatch.root,
6770 config_path: &cli.config,
6771 output,
6772 json_style: dispatch.json_style,
6773 no_cache: cli.no_cache,
6774 threads: dispatch.threads,
6775 quiet,
6776 allow_remote_extends: cli.allow_remote_extends,
6777 overrides: args.overrides,
6778 top: args.top,
6779 baseline_path: cli.baseline.as_deref(),
6780 baseline_flag: "--baseline",
6781 save_baseline_path: cli.save_baseline.as_deref(),
6782 fail_on_stale_baseline: cli.fail_on_stale_baseline,
6783 fail_on_issues: dispatch.fail_on_issues,
6784 production,
6785 production_override: Some(production),
6786 trace: args.trace.as_deref(),
6787 changed_since: cli.changed_since.as_deref(),
6788 diff_index: None,
6789 use_shared_diff_index: true,
6790 changed_files: None,
6791 change_scope_owner: fallow_engine::change_scope::ChangeScopeOwner::Run,
6792 no_package_baselines: cli.no_package_baselines,
6793 workspace: cli.workspace.as_deref(),
6794 changed_workspaces: cli.changed_workspaces.as_deref(),
6795 explain: cli.explain,
6796 explain_skipped: cli.explain_skipped,
6797 summary: cli.summary,
6798 group_by: cli.group_by,
6799 performance: cli.performance,
6800 include_fragments: !args.no_fragments,
6801 retain_unfiltered_report: false,
6802 scope: args.scope.clone(),
6803 })
6804}
6805
6806struct AuditDispatchArgs {
6807 production_dead_code: bool,
6808 production_health: bool,
6809 production_dupes: bool,
6810 dead_code_baseline: Option<PathBuf>,
6811 health_baseline: Option<PathBuf>,
6812 dupes_baseline: Option<PathBuf>,
6813 max_crap: Option<f64>,
6814 coverage: Option<PathBuf>,
6815 coverage_root: Option<PathBuf>,
6816 no_css: bool,
6817 css_deep: bool,
6818 no_css_deep: bool,
6819 gate: Option<AuditGateArg>,
6820 runtime_coverage: Option<PathBuf>,
6821 min_invocations_hot: u64,
6822 gate_marker: Option<String>,
6823 brief: bool,
6824 max_decisions: usize,
6825 walkthrough_guide: bool,
6827 walkthrough_file: Option<PathBuf>,
6830 walkthrough: bool,
6832 mark_viewed: Vec<PathBuf>,
6834 show_cleared: bool,
6836 show_deprioritized: bool,
6838 scope: Option<PathBuf>,
6839}
6840
6841struct ResolvedAuditInputs {
6842 audit_cfg: fallow_config::AuditConfig,
6843 cache_dir: PathBuf,
6844 production: ProductionModes,
6845 dead_code_baseline: Option<PathBuf>,
6846 health_baseline: Option<PathBuf>,
6847 dupes_baseline: Option<PathBuf>,
6848 coverage: Option<PathBuf>,
6852 coverage_root: Option<PathBuf>,
6853}
6854
6855fn dispatch_audit(dispatch: &DispatchContext<'_>, args: &AuditDispatchArgs) -> ExitCode {
6856 let cli = dispatch.cli;
6857 let output = dispatch.output;
6858
6859 if cli.baseline.is_some() || cli.save_baseline.is_some() {
6860 return emit_error(
6861 "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>`)",
6862 2,
6863 output,
6864 );
6865 }
6866
6867 let inputs = match resolve_audit_inputs(dispatch, args) {
6868 Ok(inputs) => inputs,
6869 Err(code) => return code,
6870 };
6871
6872 if cli.fail_on_baseline_growth && audit_renders_brief(args) {
6875 baseline_growth::note_stood_down(
6876 dispatch.growth_flags(),
6877 "the review brief never fails a run",
6878 );
6879 return run_resolved_audit(dispatch, args, &inputs);
6880 }
6881 let growth_targets = baseline_growth::targets(&[
6882 (
6883 inputs.dead_code_baseline.as_deref(),
6884 fallow_engine::baseline::BaselineKind::DeadCode,
6885 ),
6886 (
6887 inputs.health_baseline.as_deref(),
6888 fallow_engine::baseline::BaselineKind::Health,
6889 ),
6890 (
6891 inputs.dupes_baseline.as_deref(),
6892 fallow_engine::baseline::BaselineKind::Dupes,
6893 ),
6894 ]);
6895 baseline_growth::run_gated(
6896 &dispatch.growth_ctx(baseline_growth::GrowthOwner::Audit),
6897 &growth_targets,
6898 || run_resolved_audit(dispatch, args, &inputs),
6899 )
6900}
6901
6902const fn audit_renders_brief(args: &AuditDispatchArgs) -> bool {
6903 args.brief || args.walkthrough_guide || args.walkthrough || args.walkthrough_file.is_some()
6904}
6905
6906fn resolve_audit_inputs(
6907 dispatch: &DispatchContext<'_>,
6908 args: &AuditDispatchArgs,
6909) -> Result<ResolvedAuditInputs, ExitCode> {
6910 let cli = dispatch.cli;
6911 let root = dispatch.root;
6912 let output = dispatch.output;
6913 let config = load_config(
6914 root,
6915 &cli.config,
6916 LoadConfigArgs {
6917 output,
6918 no_cache: cli.no_cache,
6919 threads: dispatch.threads,
6920 production: cli.production,
6921 quiet: dispatch.quiet,
6922 allow_remote_extends: cli.allow_remote_extends,
6923 },
6924 )?;
6925 let cache_dir = config.cache_dir.clone();
6926 let audit_cfg = config.audit;
6927 let production = resolve_production_modes(
6928 cli,
6929 root,
6930 output,
6931 args.production_dead_code,
6932 args.production_health,
6933 args.production_dupes,
6934 )?;
6935 let resolved_dead_code_baseline = resolve_audit_baseline_path(
6936 root,
6937 args.dead_code_baseline.as_deref(),
6938 audit_cfg.dead_code_baseline.as_deref(),
6939 );
6940 let resolved_health_baseline = resolve_audit_baseline_path(
6941 root,
6942 args.health_baseline.as_deref(),
6943 audit_cfg.health_baseline.as_deref(),
6944 );
6945 let resolved_dupes_baseline = resolve_audit_baseline_path(
6946 root,
6947 args.dupes_baseline.as_deref(),
6948 audit_cfg.dupes_baseline.as_deref(),
6949 );
6950 let coverage_inputs = resolve_coverage_inputs(
6951 args.coverage.as_deref(),
6952 args.coverage_root.as_deref(),
6953 output,
6954 || Ok(config.health),
6955 )?;
6956
6957 Ok(ResolvedAuditInputs {
6958 audit_cfg,
6959 cache_dir,
6960 production,
6961 dead_code_baseline: resolved_dead_code_baseline,
6962 health_baseline: resolved_health_baseline,
6963 dupes_baseline: resolved_dupes_baseline,
6964 coverage: coverage_inputs.coverage,
6965 coverage_root: coverage_inputs.coverage_root,
6966 })
6967}
6968
6969fn audit_css_enabled(config: &fallow_config::AuditConfig, args: &AuditDispatchArgs) -> bool {
6970 !args.no_css && config.css.unwrap_or(true)
6971}
6972
6973fn audit_css_deep_enabled(config: &fallow_config::AuditConfig, args: &AuditDispatchArgs) -> bool {
6974 audit_css_enabled(config, args)
6975 && !args.no_css_deep
6976 && (args.css_deep || config.css_deep.unwrap_or(true))
6977}
6978
6979fn run_resolved_audit(
6980 dispatch: &DispatchContext<'_>,
6981 args: &AuditDispatchArgs,
6982 inputs: &ResolvedAuditInputs,
6983) -> ExitCode {
6984 let cli = dispatch.cli;
6985 audit::run_audit_with_type_aware(
6986 &audit::AuditOptions {
6987 root: dispatch.root,
6988 config_path: &cli.config,
6989 cache_dir: &inputs.cache_dir,
6990 output: dispatch.output,
6991 json_style: dispatch.json_style,
6992 no_cache: cli.no_cache,
6993 threads: dispatch.threads,
6994 quiet: dispatch.quiet,
6995 allow_remote_extends: cli.allow_remote_extends,
6996 changed_since: cli.changed_since.as_deref(),
6997 production: cli.production,
6998 production_dead_code: Some(inputs.production.dead_code),
6999 production_health: Some(inputs.production.health),
7000 production_dupes: Some(inputs.production.dupes),
7001 workspace: cli.workspace.as_deref(),
7002 changed_workspaces: cli.changed_workspaces.as_deref(),
7003 explain: cli.explain,
7004 explain_skipped: cli.explain_skipped,
7005 performance: cli.performance,
7006 group_by: cli.group_by,
7007 dead_code_baseline: inputs.dead_code_baseline.as_deref(),
7008 health_baseline: inputs.health_baseline.as_deref(),
7009 dupes_baseline: inputs.dupes_baseline.as_deref(),
7010 health_baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
7011 fail_on_stale_baseline: cli.fail_on_stale_baseline,
7012 max_crap: args.max_crap,
7013 coverage: inputs.coverage.as_deref(),
7014 coverage_root: inputs.coverage_root.as_deref(),
7015 gate: args.gate.map_or(inputs.audit_cfg.gate, Into::into),
7016 include_entry_exports: cli.include_entry_exports,
7017 show_cascade: cli.show_cascade,
7018 fail_on_parse_error: cli.fail_on_parse_error,
7019 css: audit_css_enabled(&inputs.audit_cfg, args),
7023 css_deep: audit_css_deep_enabled(&inputs.audit_cfg, args),
7024 runtime_coverage: args.runtime_coverage.as_deref(),
7025 min_invocations_hot: args.min_invocations_hot,
7026 brief: args.brief,
7027 max_decisions: args.max_decisions,
7028 walkthrough_guide: args.walkthrough_guide,
7029 walkthrough: args.walkthrough,
7030 mark_viewed: &args.mark_viewed,
7031 show_cleared: args.show_cleared,
7032 walkthrough_file: args.walkthrough_file.as_deref(),
7033 show_deprioritized: args.show_deprioritized,
7034 scope: args.scope.clone(),
7035 },
7036 args.gate_marker.as_deref(),
7037 audit::AuditTypeAwareOptions {
7038 enabled: cli.type_aware_override(),
7039 config_default: inputs.audit_cfg.type_aware,
7040 projects: &cli.type_aware_project,
7041 require: cli.type_aware_require.map(Into::into),
7042 },
7043 )
7044}
7045
7046fn dispatch_decision_surface(dispatch: &DispatchContext<'_>, max_decisions: usize) -> ExitCode {
7050 let args = decision_surface_audit_args(max_decisions);
7051 let inputs = match resolve_audit_inputs(dispatch, &args) {
7052 Ok(inputs) => inputs,
7053 Err(code) => return code,
7054 };
7055 if dispatch.cli.fail_on_stale_baseline {
7059 for path in [
7060 &inputs.dead_code_baseline,
7061 &inputs.health_baseline,
7062 &inputs.dupes_baseline,
7063 ] {
7064 baseline_gate::note_stood_down(
7065 path.as_deref(),
7066 true,
7067 "decision-surface renders a brief without exit gates",
7068 );
7069 }
7070 }
7071 audit::run_decision_surface(&decision_surface_audit_options(
7072 dispatch,
7073 &inputs,
7074 max_decisions,
7075 ))
7076}
7077
7078fn decision_surface_audit_args(max_decisions: usize) -> AuditDispatchArgs {
7079 AuditDispatchArgs {
7080 production_dead_code: false,
7081 production_health: false,
7082 production_dupes: false,
7083 dead_code_baseline: None,
7084 health_baseline: None,
7085 dupes_baseline: None,
7086 max_crap: None,
7087 coverage: None,
7088 coverage_root: None,
7089 no_css: true,
7090 css_deep: false,
7091 no_css_deep: false,
7092 gate: None,
7093 runtime_coverage: None,
7094 min_invocations_hot: 0,
7095 gate_marker: None,
7096 brief: true,
7097 max_decisions,
7098 walkthrough_guide: false,
7099 walkthrough_file: None,
7100 walkthrough: false,
7101 mark_viewed: Vec::new(),
7102 show_cleared: false,
7103 show_deprioritized: false,
7104 scope: None,
7105 }
7106}
7107
7108fn decision_surface_audit_options<'a>(
7109 dispatch: &'a DispatchContext<'a>,
7110 inputs: &'a ResolvedAuditInputs,
7111 max_decisions: usize,
7112) -> audit::AuditOptions<'a> {
7113 let cli = dispatch.cli;
7114 audit::AuditOptions {
7115 root: dispatch.root,
7116 config_path: &cli.config,
7117 cache_dir: &inputs.cache_dir,
7118 output: dispatch.output,
7119 json_style: dispatch.json_style,
7120 no_cache: cli.no_cache,
7121 threads: dispatch.threads,
7122 quiet: dispatch.quiet,
7123 allow_remote_extends: cli.allow_remote_extends,
7124 changed_since: cli.changed_since.as_deref(),
7125 production: cli.production,
7126 production_dead_code: Some(inputs.production.dead_code),
7127 production_health: Some(inputs.production.health),
7128 production_dupes: Some(inputs.production.dupes),
7129 workspace: cli.workspace.as_deref(),
7130 changed_workspaces: cli.changed_workspaces.as_deref(),
7131 explain: cli.explain,
7132 explain_skipped: cli.explain_skipped,
7133 performance: cli.performance,
7134 group_by: cli.group_by,
7135 dead_code_baseline: inputs.dead_code_baseline.as_deref(),
7136 health_baseline: inputs.health_baseline.as_deref(),
7137 dupes_baseline: inputs.dupes_baseline.as_deref(),
7138 health_baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
7139 fail_on_stale_baseline: false,
7143 max_crap: None,
7144 coverage: None,
7145 coverage_root: None,
7146 gate: inputs.audit_cfg.gate,
7147 include_entry_exports: cli.include_entry_exports,
7148 show_cascade: cli.show_cascade,
7149 fail_on_parse_error: false,
7150 css: false,
7152 css_deep: false,
7153 runtime_coverage: None,
7154 min_invocations_hot: 0,
7155 brief: true,
7156 max_decisions,
7157 walkthrough_guide: false,
7158 walkthrough: false,
7159 mark_viewed: &[],
7160 show_cleared: false,
7161 walkthrough_file: None,
7162 show_deprioritized: false,
7163 scope: None,
7164 }
7165}
7166
7167struct HealthDispatchArgs<'a> {
7168 max_cyclomatic: Option<u16>,
7169 max_cognitive: Option<u16>,
7170 max_crap: Option<f64>,
7171 top: Option<usize>,
7172 sort: health::SortBy,
7173 complexity: bool,
7174 complexity_breakdown: bool,
7175 file_scores: bool,
7176 coverage_gaps: bool,
7177 hotspots: bool,
7178 ownership: bool,
7179 ownership_emails: Option<fallow_config::EmailMode>,
7180 targets: bool,
7181 type_coupling: bool,
7182 css: bool,
7183 effort: Option<EffortFilter>,
7184 score: bool,
7185 min_score: Option<f64>,
7186 min_severity: Option<fallow_output::FindingSeverity>,
7187 report_only: bool,
7188 fail_on_stale_baseline: bool,
7189 fail_on_parse_error: bool,
7190 since: Option<&'a str>,
7191 min_commits: Option<u32>,
7192 save_snapshot: Option<&'a Option<String>>,
7193 trend: bool,
7194 trend_from: Option<&'a std::path::Path>,
7195 coverage: Option<&'a std::path::Path>,
7196 coverage_root: Option<&'a std::path::Path>,
7197 runtime_coverage: Option<&'a std::path::Path>,
7198 min_invocations_hot: u64,
7199 min_observation_volume: Option<u32>,
7200 low_traffic_threshold: Option<f64>,
7201 scope: Option<std::path::PathBuf>,
7202}
7203
7204type ResolvedHealthCoverageInputs = fallow_api::CoverageInputs;
7205
7206fn resolve_coverage_inputs(
7217 cli_coverage: Option<&std::path::Path>,
7218 cli_coverage_root: Option<&std::path::Path>,
7219 output: fallow_config::OutputFormat,
7220 config_health: impl FnOnce() -> Result<fallow_config::HealthConfig, ExitCode>,
7221) -> Result<ResolvedHealthCoverageInputs, ExitCode> {
7222 let explicit = fallow_api::CoverageInputs {
7223 coverage: cli_coverage.map(std::path::Path::to_path_buf),
7224 coverage_root: cli_coverage_root.map(std::path::Path::to_path_buf),
7225 };
7226 let env = fallow_api::CoverageInputs {
7227 coverage: path_from_env("FALLOW_COVERAGE"),
7228 coverage_root: path_from_env("FALLOW_COVERAGE_ROOT"),
7229 };
7230 let config_health = if fallow_api::CoverageInputs::needs_config_layer(&explicit, &env) {
7231 Some(config_health()?)
7232 } else {
7233 None
7234 };
7235
7236 fallow_api::resolve_coverage_inputs(explicit, env, config_health.as_ref())
7237 .map_err(|err| emit_error(&err.to_string(), 2, output))
7238}
7239
7240fn resolve_health_coverage_inputs(
7243 dispatch: &DispatchContext<'_>,
7244 cli_coverage: Option<&std::path::Path>,
7245 cli_coverage_root: Option<&std::path::Path>,
7246) -> Result<ResolvedHealthCoverageInputs, ExitCode> {
7247 resolve_coverage_inputs(cli_coverage, cli_coverage_root, dispatch.output, || {
7248 Ok(load_config(
7249 dispatch.root,
7250 &dispatch.cli.config,
7251 LoadConfigArgs {
7252 output: dispatch.output,
7253 no_cache: dispatch.cli.no_cache,
7254 threads: dispatch.threads,
7255 production: dispatch.cli.production,
7256 quiet: dispatch.quiet,
7257 allow_remote_extends: dispatch.cli.allow_remote_extends,
7258 },
7259 )?
7260 .health)
7261 })
7262}
7263
7264fn path_from_env(name: &str) -> Option<PathBuf> {
7265 std::env::var_os(name)
7266 .filter(|value| !value.is_empty())
7267 .map(PathBuf::from)
7268}
7269
7270fn validate_health_report_only_gate(
7271 report_only: bool,
7272 min_score: Option<f64>,
7273 min_severity: Option<fallow_output::FindingSeverity>,
7274 output: fallow_config::OutputFormat,
7275) -> Result<(), ExitCode> {
7276 if report_only && (min_score.is_some() || min_severity.is_some()) {
7277 return Err(emit_error(
7278 "--report-only cannot be combined with --min-score or --min-severity. \
7279 --report-only always exits 0; drop it to gate on score/severity, or \
7280 drop the gate flags to stay advisory.",
7281 2,
7282 output,
7283 ));
7284 }
7285
7286 Ok(())
7287}
7288
7289fn resolve_runtime_coverage_options(
7290 runtime_coverage: Option<&std::path::Path>,
7291 min_invocations_hot: u64,
7292 min_observation_volume: Option<u32>,
7293 low_traffic_threshold: Option<f64>,
7294 output: fallow_config::OutputFormat,
7295) -> Result<Option<fallow_engine::health::RuntimeCoverageOptions>, ExitCode> {
7296 let Some(path) = runtime_coverage else {
7297 return Ok(None);
7298 };
7299
7300 health::coverage::prepare_options(
7301 path,
7302 min_invocations_hot,
7303 min_observation_volume,
7304 low_traffic_threshold,
7305 output,
7306 )
7307 .map(Some)
7308}
7309
7310fn dispatch_health(dispatch: &DispatchContext<'_>, args: &HealthDispatchArgs<'_>) -> ExitCode {
7311 let cli = dispatch.cli;
7312 let root = dispatch.root;
7313 let (output, _quiet, _fail_on_issues) =
7314 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
7315 if let Err(code) = validate_health_report_only_gate(
7316 args.report_only,
7317 args.min_score,
7318 args.min_severity,
7319 output,
7320 ) {
7321 return code;
7322 }
7323 let runtime_coverage = match resolve_runtime_coverage_options(
7324 args.runtime_coverage,
7325 args.min_invocations_hot,
7326 args.min_observation_volume,
7327 args.low_traffic_threshold,
7328 output,
7329 ) {
7330 Ok(options) => options,
7331 Err(code) => return code,
7332 };
7333 let production = match resolve_production_modes(cli, root, output, false, false, false) {
7334 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::Health),
7335 Err(code) => return code,
7336 };
7337 let coverage_inputs =
7338 match resolve_health_coverage_inputs(dispatch, args.coverage, args.coverage_root) {
7339 Ok(inputs) => inputs,
7340 Err(code) => return code,
7341 };
7342 let run = derive_health_dispatch_run(args, output, &coverage_inputs, runtime_coverage);
7343 let resolved = ResolvedHealthDispatch { run, production };
7344 if args.report_only && cli.fail_on_baseline_growth {
7347 baseline_growth::note_stood_down(
7348 dispatch.growth_flags(),
7349 "health --report-only never fails a run",
7350 );
7351 return run_health_dispatch(dispatch, args, resolved);
7352 }
7353 let growth_targets = baseline_growth::targets(&[(
7354 cli.baseline.as_deref(),
7355 fallow_engine::baseline::BaselineKind::Health,
7356 )]);
7357 baseline_growth::run_gated(
7358 &dispatch.growth_ctx(baseline_growth::GrowthOwner::Health),
7359 &growth_targets,
7360 || run_health_dispatch(dispatch, args, resolved),
7361 )
7362}
7363
7364fn derive_health_dispatch_run<'a>(
7365 args: &'a HealthDispatchArgs<'a>,
7366 output: fallow_config::OutputFormat,
7367 coverage_inputs: &'a ResolvedHealthCoverageInputs,
7368 runtime_coverage: Option<fallow_engine::health::RuntimeCoverageOptions>,
7369) -> fallow_engine::health::HealthRunOptions<'a> {
7370 let mut run = fallow_engine::health::derive_health_run_options(
7371 fallow_engine::health::HealthRunOptionsInput {
7372 output,
7373 thresholds: health_threshold_overrides(args),
7374 top: args.top,
7375 sort: args.sort.clone().into(),
7376 complexity: args.complexity,
7377 file_scores: args.file_scores,
7378 coverage_gaps: args.coverage_gaps,
7379 hotspots: args.hotspots,
7380 ownership: args.ownership,
7381 ownership_emails: args.ownership_emails,
7382 targets: args.targets,
7383 css: args.css,
7384 effort: args.effort.map(EffortFilter::to_estimate),
7385 score: args.score,
7386 gates: health_gate_options(args),
7387 snapshot_requested: args.save_snapshot.is_some(),
7388 trend: args.trend,
7389 since: args.since,
7390 min_commits: args.min_commits,
7391 coverage_inputs: health_coverage_inputs(coverage_inputs),
7392 runtime_coverage,
7393 },
7394 );
7395 if args.type_coupling && !run.sections.any_section {
7396 run.sections = fallow_engine::health::DerivedHealthSections {
7397 any_section: true,
7398 complexity: false,
7399 file_scores: false,
7400 coverage_gaps: false,
7401 hotspots: false,
7402 targets: false,
7403 css: false,
7404 score: false,
7405 force_full: false,
7406 score_only_output: false,
7407 };
7408 }
7409 run
7410}
7411
7412fn health_threshold_overrides(
7413 args: &HealthDispatchArgs<'_>,
7414) -> fallow_engine::health::HealthThresholdOverrides {
7415 fallow_engine::health::HealthThresholdOverrides {
7416 max_cyclomatic: args.max_cyclomatic,
7417 max_cognitive: args.max_cognitive,
7418 max_crap: args.max_crap,
7419 }
7420}
7421
7422fn health_gate_options(args: &HealthDispatchArgs<'_>) -> fallow_engine::health::HealthGateOptions {
7423 fallow_engine::health::HealthGateOptions {
7424 min_score: args.min_score,
7425 min_severity: args.min_severity,
7426 report_only: args.report_only,
7427 fail_on_stale_baseline: args.fail_on_stale_baseline,
7428 fail_on_parse_error: args.fail_on_parse_error,
7429 fail_on_issues: false,
7430 }
7431}
7432
7433fn health_coverage_inputs(
7434 coverage_inputs: &ResolvedHealthCoverageInputs,
7435) -> fallow_engine::health::HealthCoverageInputs<'_> {
7436 fallow_engine::health::HealthCoverageInputs {
7437 coverage: coverage_inputs.coverage.as_deref(),
7438 coverage_root: coverage_inputs.coverage_root.as_deref(),
7439 coverage_relocated: false,
7440 }
7441}
7442
7443struct ResolvedHealthDispatch<'a> {
7447 run: fallow_engine::health::HealthRunOptions<'a>,
7448 production: bool,
7449}
7450
7451fn run_health_dispatch(
7454 dispatch: &DispatchContext<'_>,
7455 args: &HealthDispatchArgs<'_>,
7456 resolved: ResolvedHealthDispatch<'_>,
7457) -> ExitCode {
7458 let cli = dispatch.cli;
7459 let (output, quiet, fail_on_issues) =
7460 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
7461 let mut run = resolved.run;
7462 run.gates.fail_on_issues = fail_on_issues;
7463 let sections = run.sections;
7464 let production = resolved.production;
7465 health::run_health(
7466 &HealthOptions {
7467 root: dispatch.root,
7468 config_path: &cli.config,
7469 output,
7470 no_cache: cli.no_cache,
7471 threads: dispatch.threads,
7472 quiet,
7473 thresholds: run.thresholds,
7474 top: run.top,
7475 sort: run.sort,
7476 production,
7477 production_override: Some(production),
7478 allow_remote_extends: cli.allow_remote_extends,
7479 changed_since: cli.changed_since.as_deref(),
7480 diff_index: None,
7481 use_shared_diff_index: true,
7482 workspace: cli.workspace.as_deref(),
7483 changed_workspaces: cli.changed_workspaces.as_deref(),
7484 baseline: cli.baseline.as_deref(),
7485 save_baseline: cli.save_baseline.as_deref(),
7486 baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
7487 baseline_mode_explicit: cli.baseline_mode.is_some(),
7488 complexity: sections.complexity,
7489 file_scores: sections.file_scores,
7490 coverage_gaps: sections.coverage_gaps,
7491 config_activates_coverage_gaps: !sections.any_section,
7492 hotspots: sections.hotspots,
7493 ownership: run.ownership,
7494 ownership_emails: run.ownership_emails,
7495 targets: sections.targets,
7496 css: sections.css,
7497 css_deep: false,
7498 force_full: sections.force_full,
7499 score_only_output: sections.score_only_output,
7500 enforce_coverage_gap_gate: true,
7501 effort: run.effort,
7502 score: sections.score,
7503 gates: run.gates,
7504 since: run.since,
7505 min_commits: run.min_commits,
7506 explain: cli.explain,
7507 summary: cli.summary,
7508 save_snapshot: args
7509 .save_snapshot
7510 .map(|opt| PathBuf::from(opt.as_deref().unwrap_or_default())),
7511 trend: args.trend,
7512 coverage_inputs: run.coverage_inputs,
7513 performance: cli.performance,
7514 runtime_coverage: run.runtime_coverage,
7515 churn_file: cli.churn_file.as_deref(),
7516 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
7517 complexity_breakdown: args.complexity_breakdown,
7518 group_by: cli.group_by.map(Into::into),
7519 group_filter: cli.group.as_deref(),
7520 trend_from: args.trend_from,
7521 scope: args.scope.clone(),
7522 },
7523 dispatch.json_style,
7524 &health::TypeAwareHealthOptions {
7525 enabled: cli.type_aware_override(),
7526 requested: args.type_coupling,
7527 unfiltered: health_type_coupling_is_default_section(args),
7528 projects: &cli.type_aware_project,
7529 require: cli.type_aware_require.map(Into::into),
7530 },
7531 )
7532}
7533
7534fn health_type_coupling_is_default_section(args: &HealthDispatchArgs<'_>) -> bool {
7535 !args.complexity
7536 && !args.file_scores
7537 && !args.coverage_gaps
7538 && !args.hotspots
7539 && !args.ownership
7540 && !args.targets
7541 && !args.css
7542 && !args.score
7543 && args.min_score.is_none()
7544 && args.min_severity.is_none()
7545 && args.runtime_coverage.is_none()
7546}
7547
7548#[cfg(test)]
7549mod tests {
7550 use super::*;
7551
7552 #[test]
7556 fn cli_definition_has_no_flag_collisions() {
7557 use clap::CommandFactory;
7558 Cli::command().debug_assert();
7559 }
7560
7561 #[test]
7562 fn impact_statusline_subcommand_parses() {
7563 use clap::Parser;
7564
7565 let cli = Cli::try_parse_from(["fallow", "impact", "statusline"]).expect("argv parses");
7566 assert!(matches!(
7567 cli.command,
7568 Some(Command::Impact {
7569 subcommand: Some(ImpactCli::Statusline),
7570 ..
7571 })
7572 ));
7573 }
7574
7575 #[test]
7576 fn impact_statusline_bypasses_command_epilogue() {
7577 use clap::Parser;
7578
7579 let statusline =
7580 Cli::try_parse_from(["fallow", "impact", "statusline"]).expect("argv parses");
7581 assert!(is_impact_statusline(&statusline));
7582
7583 let status = Cli::try_parse_from(["fallow", "impact", "status"]).expect("argv parses");
7584 assert!(!is_impact_statusline(&status));
7585
7586 let all_statusline =
7587 Cli::try_parse_from(["fallow", "impact", "--all", "statusline"]).expect("argv parses");
7588 assert!(!is_impact_statusline(&all_statusline));
7589 }
7590
7591 #[test]
7592 fn regression_baseline_help_explains_the_default_destination() {
7593 use clap::CommandFactory;
7594 let help = Cli::command().render_long_help().to_string();
7595
7596 assert!(help.contains("Omit PATH to update regression.baseline"));
7597 assert!(help.contains("discovered fallow config"));
7598 assert!(help.contains("create .fallowrc.json when none exists"));
7599 }
7600
7601 #[test]
7605 fn after_help_lists_every_task_matrix_command() {
7606 for row in crate::task_matrix::TASK_MATRIX {
7607 assert!(
7608 TOP_LEVEL_AFTER_LONG_HELP.contains(row.command),
7609 "root --help cheat sheet is missing task-matrix command '{}'; \
7610 update the top_level_task_cheat_sheet! fragment to match TASK_MATRIX",
7611 row.command
7612 );
7613 }
7614 }
7615
7616 #[test]
7623 fn after_help_lists_every_visible_subcommand() {
7624 use clap::CommandFactory;
7625
7626 for sub in Cli::command().get_subcommands() {
7627 if sub.is_hide_set() {
7628 continue;
7629 }
7630 let name = sub.get_name();
7631 let listed = TOP_LEVEL_AFTER_LONG_HELP
7632 .lines()
7633 .any(|line| line.split_whitespace().next() == Some(name));
7634 assert!(
7635 listed,
7636 "root --help command list is missing subcommand '{name}'; \
7637 add it to a top_level_*_command_groups! section"
7638 );
7639 }
7640 }
7641
7642 #[test]
7646 fn every_visible_subcommand_is_classified_for_its_machine_contract() {
7647 use clap::CommandFactory;
7648 use fallow_types::command_surfaces::{
7649 COMMAND_ENVELOPES, COMMANDS_WITHOUT_ANALYSIS_ENVELOPE,
7650 };
7651
7652 let visible: Vec<String> = Cli::command()
7653 .get_subcommands()
7654 .filter(|sub| !sub.is_hide_set() && sub.get_name() != "help")
7655 .map(|sub| sub.get_name().to_string())
7656 .collect();
7657 for name in &visible {
7658 let rows = COMMAND_ENVELOPES
7659 .iter()
7660 .filter(|row| row.command == name)
7661 .count();
7662 let others = COMMANDS_WITHOUT_ANALYSIS_ENVELOPE
7663 .iter()
7664 .filter(|(command, _)| command == name)
7665 .count();
7666 assert_eq!(
7667 rows + others,
7668 1,
7669 "subcommand `{name}` must be in exactly one of COMMAND_ENVELOPES and \
7670 COMMANDS_WITHOUT_ANALYSIS_ENVELOPE (crates/types/src/command_surfaces.rs)"
7671 );
7672 }
7673 let listed = COMMAND_ENVELOPES.iter().map(|row| row.command).chain(
7674 COMMANDS_WITHOUT_ANALYSIS_ENVELOPE
7675 .iter()
7676 .map(|(command, _)| *command),
7677 );
7678 for command in listed {
7679 assert!(
7680 visible.iter().any(|name| name == command),
7681 "`{command}` is listed in command_surfaces.rs but is not a visible subcommand"
7682 );
7683 }
7684 }
7685
7686 #[test]
7689 fn architecture_flags_map_to_the_dead_code_structure_filters() {
7690 use clap::Parser;
7691
7692 let structure = |argv: &[&str]| {
7693 let cli = Cli::try_parse_from(argv).expect("argv parses");
7694 check_issue_filters(&cli.command.expect("subcommand"))
7695 };
7696 let architecture =
7697 |cycles, boundaries, policy| architecture_issue_filters(cycles, boundaries, policy);
7698 assert_eq!(
7699 architecture(false, false, false),
7700 structure(&[
7701 "fallow",
7702 "dead-code",
7703 "--circular-deps",
7704 "--re-export-cycles",
7705 "--package-cycles",
7706 "--boundary-violations",
7707 "--policy-violations",
7708 ])
7709 );
7710 assert_eq!(
7711 architecture(true, false, false),
7712 structure(&[
7713 "fallow",
7714 "dead-code",
7715 "--circular-deps",
7716 "--re-export-cycles",
7717 "--package-cycles",
7718 ])
7719 );
7720 assert_eq!(
7721 architecture(false, true, false),
7722 structure(&["fallow", "dead-code", "--boundary-violations"])
7723 );
7724 assert_eq!(
7725 architecture(false, false, true),
7726 structure(&["fallow", "dead-code", "--policy-violations"])
7727 );
7728 assert!(
7729 Cli::try_parse_from([
7730 "fallow",
7731 "architecture",
7732 "--cycles",
7733 "--boundaries",
7734 "--policy"
7735 ])
7736 .is_ok()
7737 );
7738 }
7739
7740 #[test]
7744 fn short_help_stays_scannable_with_cheat_sheet_and_pointer() {
7745 use clap::CommandFactory;
7746
7747 let help = Cli::command().render_help().to_string();
7748 let lines = help.lines().count();
7749 assert!(
7750 lines < 90,
7751 "root -h grew to {lines} lines; keep the short surface under 90 \
7752 (curate hide_short_help and the short after-help instead)"
7753 );
7754 assert!(help.contains("When the agent is about to..."));
7755 assert!(help.contains("Run fallow --help for the complete command list."));
7756 }
7757
7758 #[test]
7762 fn high_value_commands_route_to_distinct_workflows() {
7763 use clap::Parser;
7764 use fallow_config::OutputFormat;
7765
7766 let distinct = [
7767 (vec!["fallow", "impact"], telemetry::Workflow::Impact),
7768 (vec!["fallow", "security"], telemetry::Workflow::Security),
7769 (vec!["fallow", "fix"], telemetry::Workflow::Fix),
7770 (
7771 vec!["fallow", "explain", "unused-exports"],
7772 telemetry::Workflow::Explain,
7773 ),
7774 (
7775 vec!["fallow", "watch"],
7776 telemetry::Workflow::CodeQualityReview,
7777 ),
7778 (
7779 vec!["fallow", "list"],
7780 telemetry::Workflow::ProjectInventory,
7781 ),
7782 (
7783 vec!["fallow", "workspaces"],
7784 telemetry::Workflow::ProjectInventory,
7785 ),
7786 (
7787 vec!["fallow", "schema"],
7788 telemetry::Workflow::ProjectInventory,
7789 ),
7790 (vec!["fallow", "init"], telemetry::Workflow::Setup),
7791 (
7792 vec!["fallow", "hooks", "install", "--target", "git"],
7793 telemetry::Workflow::Setup,
7794 ),
7795 (vec!["fallow", "config-schema"], telemetry::Workflow::Setup),
7796 (vec!["fallow", "plugin-schema"], telemetry::Workflow::Setup),
7797 (
7798 vec!["fallow", "rule-pack-schema"],
7799 telemetry::Workflow::Setup,
7800 ),
7801 (vec!["fallow", "config"], telemetry::Workflow::Setup),
7802 (
7803 vec!["fallow", "ci-template", "gitlab"],
7804 telemetry::Workflow::Setup,
7805 ),
7806 (vec!["fallow", "migrate"], telemetry::Workflow::Setup),
7807 (
7808 vec!["fallow", "telemetry", "status"],
7809 telemetry::Workflow::Setup,
7810 ),
7811 (vec!["fallow", "setup-hooks"], telemetry::Workflow::Setup),
7812 (
7813 vec!["fallow", "audit-cache", "remove", "--root", "."],
7814 telemetry::Workflow::Setup,
7815 ),
7816 (
7817 vec!["fallow", "license", "status"],
7818 telemetry::Workflow::License,
7819 ),
7820 (
7821 vec!["fallow", "architecture", "--cycles"],
7822 telemetry::Workflow::Architecture,
7823 ),
7824 ];
7825 for (argv, expected) in distinct {
7826 let cli = Cli::try_parse_from(&argv).expect("argv parses");
7827 assert_eq!(
7828 telemetry_workflow_for_command(cli.command.as_ref(), OutputFormat::Json),
7829 expected,
7830 "{argv:?} should map to {expected:?}"
7831 );
7832 }
7833 }
7834
7835 #[test]
7840 fn version_flag_accepts_lower_v_upper_v_and_long() {
7841 use clap::CommandFactory;
7842 for argv in [["fallow", "-v"], ["fallow", "-V"], ["fallow", "--version"]] {
7843 let err = Cli::command()
7844 .try_get_matches_from(argv)
7845 .expect_err("version flag should short-circuit parsing");
7846 assert_eq!(
7847 err.kind(),
7848 clap::error::ErrorKind::DisplayVersion,
7849 "{argv:?} should trigger the Version action"
7850 );
7851 }
7852 }
7853
7854 #[test]
7859 fn cli_help_text_contains_no_implementation_status_wording() {
7860 use clap::CommandFactory;
7861 let mut root = Cli::command();
7862 let mut violations: Vec<(String, String)> = Vec::new();
7863 visit_help(&mut root, "fallow", &mut violations);
7864 assert!(
7865 violations.is_empty(),
7866 "found implementation-status wording in --help output:\n{}",
7867 violations
7868 .iter()
7869 .map(|(cmd, line)| format!(" {cmd}: {line}"))
7870 .collect::<Vec<_>>()
7871 .join("\n")
7872 );
7873 }
7874
7875 #[test]
7876 fn dependency_override_help_is_package_manager_neutral() {
7877 use clap::CommandFactory;
7878 let help = Cli::command()
7879 .find_subcommand_mut("dead-code")
7880 .expect("dead-code command")
7881 .render_long_help()
7882 .to_string();
7883
7884 assert!(help.contains("Only report unused package-manager dependency overrides"));
7885 assert!(help.contains("Only report misconfigured package-manager dependency overrides"));
7886 assert!(!help.contains("unused pnpm dependency overrides"));
7887 assert!(!help.contains("misconfigured pnpm dependency overrides"));
7888 }
7889
7890 #[test]
7891 fn top_level_help_groups_commands_by_workflow() {
7892 use clap::CommandFactory;
7893 let help = Cli::command().render_long_help().to_string();
7894 let expected_order = [
7895 "Analysis:",
7896 " dead-code",
7897 " dupes",
7898 " health",
7899 " flags",
7900 " security",
7901 " audit",
7902 "Workflow:",
7903 " watch",
7904 " fix",
7905 "Project inspection:",
7906 " list",
7907 " workspaces",
7908 " explain",
7909 " impact",
7910 " viz",
7911 "Setup and configuration:",
7912 " init",
7913 " recommend",
7914 " migrate",
7915 " config",
7916 " config-schema",
7917 " plugin-schema",
7918 " plugin-check",
7919 " rule-pack-schema",
7920 "Automation and CI:",
7921 " ci",
7922 " ci-template",
7923 " hooks",
7924 " setup-hooks",
7925 "Runtime coverage:",
7926 " coverage",
7927 " license",
7928 "Reference:",
7929 " schema",
7930 " help",
7931 "Options:",
7932 ];
7933 let mut cursor = 0;
7934 for needle in expected_order {
7935 let Some(offset) = help[cursor..].find(needle) else {
7936 panic!("top-level help missing `{needle}` after byte {cursor}:\n{help}");
7937 };
7938 cursor += offset + needle.len();
7939 }
7940 }
7941
7942 #[test]
7946 fn every_global_dupes_override_is_rejected_where_dupes_does_not_run() {
7947 let longs: Vec<String> = <Cli as clap::CommandFactory>::command()
7948 .get_arguments()
7949 .filter_map(|arg| arg.get_long())
7950 .filter(|long| long.starts_with("dupes-") && *long != "dupes-baseline")
7951 .map(str::to_owned)
7952 .collect();
7953 assert!(
7954 longs.len() > 1,
7955 "expected global --dupes-* flags, got {longs:?}"
7956 );
7957 let security_help = render_security_help(SecurityHelpTarget::Parent);
7958
7959 for long in &longs {
7960 let flag = format!("--{long}");
7961 let takes_value = <Cli as clap::CommandFactory>::command()
7962 .get_arguments()
7963 .find(|arg| arg.get_long() == Some(long.as_str()))
7964 .is_some_and(|arg| arg.get_action().takes_values());
7965 let value = if long == "dupes-mode" { "weak" } else { "3" };
7966 let argv_for = |command: &[&str]| {
7967 let mut argv = vec!["fallow"];
7968 argv.extend_from_slice(command);
7969 argv.push(flag.as_str());
7970 if takes_value {
7971 argv.push(value);
7972 }
7973 Cli::try_parse_from(argv).expect("global dupes flag parses")
7974 };
7975
7976 let security = argv_for(&["security"]);
7977 assert_eq!(unsupported_security_global(&security), Some(flag.as_str()));
7978 let doctor = argv_for(&["doctor"]);
7979 assert_eq!(unsupported_doctor_option(&doctor), Some(flag.as_str()));
7980 let similar_code = argv_for(&["similar-code", "status"]);
7981 assert_eq!(
7982 similar_code_help::unsupported_similar_code_option(&similar_code),
7983 Some(flag.as_str())
7984 );
7985 assert!(
7986 !help_contains_long_flag(&security_help, long),
7987 "security help must hide unsupported {flag}:\n{security_help}"
7988 );
7989 }
7990 }
7991
7992 #[test]
7993 fn security_help_hides_globals_rejected_by_security_validator() {
7994 let help = render_security_help(SecurityHelpTarget::Parent);
7995
7996 for long in SECURITY_UNSUPPORTED_GLOBAL_LONGS {
7997 assert!(
7998 !help_contains_long_flag(&help, long),
7999 "security help must hide unsupported --{long}:\n{help}"
8000 );
8001 }
8002
8003 for long in [
8004 "root",
8005 "config",
8006 "format",
8007 "quiet",
8008 "no-cache",
8009 "threads",
8010 "changed-since",
8011 "diff-file",
8012 "diff-stdin",
8013 "workspace",
8014 "changed-workspaces",
8015 "ci",
8016 "fail-on-issues",
8017 "sarif-file",
8018 "summary",
8019 "output-file",
8020 "max-file-size",
8021 "explain",
8022 "surface",
8023 ] {
8024 assert!(
8025 help_contains_long_flag(&help, long),
8026 "security help must keep supported --{long}:\n{help}"
8027 );
8028 }
8029 }
8030
8031 #[test]
8032 fn security_help_detection_covers_subcommand_and_help_alias_forms() {
8033 assert_eq!(
8034 security_help_target(["security", "--help"]),
8035 Some(SecurityHelpTarget::Parent)
8036 );
8037 assert_eq!(
8038 security_help_target(["security", "-h"]),
8039 Some(SecurityHelpTarget::Parent)
8040 );
8041 assert_eq!(
8042 security_help_target(["--format", "json", "security", "--help"]),
8043 Some(SecurityHelpTarget::Parent)
8044 );
8045 assert_eq!(
8046 security_help_target(["help", "security"]),
8047 Some(SecurityHelpTarget::Parent)
8048 );
8049 assert_eq!(
8050 security_help_target(["security", "survivors", "--help"]),
8051 Some(SecurityHelpTarget::Survivors)
8052 );
8053 assert_eq!(
8054 security_help_target(["security", "survivors", "-h"]),
8055 Some(SecurityHelpTarget::Survivors)
8056 );
8057 assert_eq!(
8058 security_help_target(["help", "security", "survivors"]),
8059 Some(SecurityHelpTarget::Survivors)
8060 );
8061 assert_eq!(
8062 security_help_target(["security", "blind-spots", "--help"]),
8063 Some(SecurityHelpTarget::BlindSpots)
8064 );
8065 assert_eq!(
8066 security_help_target(["help", "security", "blind-spots"]),
8067 Some(SecurityHelpTarget::BlindSpots)
8068 );
8069 assert_eq!(security_help_target(["health", "--help"]), None);
8070 assert_eq!(security_help_target(["help", "health"]), None);
8071 }
8072
8073 #[test]
8074 fn security_unsupported_global_validator_matches_hidden_help_contract() {
8075 for (argv, expected) in [
8076 (vec!["fallow", "security", "--performance"], "--performance"),
8077 (
8078 vec!["fallow", "security", "--no-package-baselines"],
8079 "--no-package-baselines",
8080 ),
8081 (
8082 vec!["fallow", "security", "--baseline", "base.json"],
8083 "--baseline",
8084 ),
8085 (
8086 vec!["fallow", "security", "--fail-on-stale-baseline"],
8087 "--fail-on-stale-baseline",
8088 ),
8089 (
8090 vec!["fallow", "security", "--dupes-mode", "weak"],
8091 "--dupes-mode",
8092 ),
8093 (vec!["fallow", "security", "--dupes-near"], "--dupes-near"),
8094 ] {
8095 let cli = Cli::try_parse_from(argv).expect("security global parses before validation");
8096 assert_eq!(unsupported_security_global(&cli), Some(expected));
8097 }
8098
8099 let explain = Cli::try_parse_from(["fallow", "security", "--explain"])
8100 .expect("security --explain parses");
8101 assert_eq!(unsupported_security_global(&explain), None);
8102 }
8103
8104 #[test]
8105 fn programmatic_common_options_track_analysis_affecting_cli_globals() {
8106 use clap::CommandFactory;
8107
8108 let cli_flags: std::collections::BTreeSet<String> = Cli::command()
8109 .get_arguments()
8110 .filter(|arg| arg.is_global_set())
8111 .filter_map(|arg| arg.get_long().map(str::to_owned))
8112 .filter(|name| {
8113 matches!(
8114 name.as_str(),
8115 "root"
8116 | "config"
8117 | "allow-remote-extends"
8118 | "no-cache"
8119 | "threads"
8120 | "changed-since"
8121 | "diff-file"
8122 | "production"
8123 | "workspace"
8124 | "changed-workspaces"
8125 | "explain"
8126 )
8127 })
8128 .collect();
8129 let programmatic_flags: std::collections::BTreeSet<String> =
8130 fallow_api::COMMON_ANALYSIS_OPTION_FLAGS
8131 .iter()
8132 .map(|flag| (*flag).to_owned())
8133 .collect();
8134
8135 assert_eq!(programmatic_flags, cli_flags);
8136 }
8137
8138 #[test]
8139 fn dead_code_registry_filter_flags_are_exposed_by_clap() {
8140 use clap::CommandFactory;
8141
8142 let cli = Cli::command();
8143 let dead_code = cli
8144 .get_subcommands()
8145 .find(|command| command.get_name() == "dead-code")
8146 .expect("dead-code subcommand is registered");
8147 let cli_flags: std::collections::BTreeSet<String> = dead_code
8148 .get_arguments()
8149 .filter_map(|arg| arg.get_long().map(|long| format!("--{long}")))
8150 .collect();
8151
8152 for flag in fallow_types::issue_meta::DEAD_CODE_FILTER_FLAGS.iter() {
8153 assert!(
8154 cli_flags.contains(*flag),
8155 "registry filter flag {flag} is missing from dead-code clap args"
8156 );
8157 }
8158 }
8159
8160 fn help_contains_long_flag(help: &str, long: &str) -> bool {
8161 let flag = format!("--{long}");
8162 help.split(|c: char| c.is_whitespace() || c == ',' || c == '[' || c == ']')
8163 .any(|token| token == flag)
8164 }
8165
8166 fn visit_help(cmd: &mut clap::Command, path: &str, violations: &mut Vec<(String, String)>) {
8167 let help = cmd.render_long_help().to_string();
8168 for line in scan_forbidden(&help) {
8169 violations.push((path.to_owned(), line));
8170 }
8171 let names: Vec<String> = cmd
8172 .get_subcommands()
8173 .map(|sub| sub.get_name().to_owned())
8174 .collect();
8175 for name in names {
8176 if name == "help" {
8177 continue;
8178 }
8179 if let Some(sub) = cmd.find_subcommand_mut(&name) {
8180 let sub_path = format!("{path} {name}");
8181 visit_help(sub, &sub_path, violations);
8182 }
8183 }
8184 }
8185
8186 fn scan_forbidden(s: &str) -> Vec<String> {
8187 let lower = s.to_ascii_lowercase();
8188 let mut out = Vec::new();
8189 for word in ["stub", "placeholder"] {
8190 if let Some(idx) = find_whole_word(&lower, word) {
8191 out.push(extract_line(s, idx));
8192 }
8193 }
8194 if let Some(idx) = lower.find("not yet") {
8195 out.push(extract_line(s, idx));
8196 }
8197 out
8198 }
8199
8200 fn find_whole_word(haystack: &str, word: &str) -> Option<usize> {
8201 let bytes = haystack.as_bytes();
8202 let mut start = 0;
8203 while let Some(rel) = haystack[start..].find(word) {
8204 let abs = start + rel;
8205 let before_ok = abs == 0 || !bytes[abs - 1].is_ascii_alphanumeric();
8206 let after_idx = abs + word.len();
8207 let after_ok = after_idx >= bytes.len() || !bytes[after_idx].is_ascii_alphanumeric();
8208 if before_ok && after_ok {
8209 return Some(abs);
8210 }
8211 start = abs + word.len();
8212 }
8213 None
8214 }
8215
8216 fn extract_line(s: &str, byte_idx: usize) -> String {
8217 let line_start = s[..byte_idx].rfind('\n').map_or(0, |i| i + 1);
8218 let line_end = s[byte_idx..].find('\n').map_or(s.len(), |i| byte_idx + i);
8219 s[line_start..line_end].trim().to_owned()
8220 }
8221
8222 #[test]
8223 fn emit_error_returns_given_exit_code() {
8224 let code = emit_error("test error", 2, fallow_config::OutputFormat::Human);
8225 assert_eq!(code, ExitCode::from(2));
8226 }
8227
8228 fn telemetry_run_for_mode(mode: telemetry::AnalysisMode) -> TelemetryRun {
8229 TelemetryRun {
8230 workflow: telemetry::Workflow::Health,
8231 output: fallow_config::OutputFormat::Json,
8232 quiet: true,
8233 start: std::time::Instant::now(),
8234 context: telemetry::WorkflowContext {
8235 run_scope: telemetry::RunScope::FullProject,
8236 config_shape: telemetry::ConfigShape::Default,
8237 output_destination: telemetry::OutputDestination::Stdout,
8238 analysis_mode: mode,
8239 },
8240 }
8241 }
8242
8243 #[test]
8244 fn fallback_failure_reason_skips_success_and_findings() {
8245 let run = telemetry_run_for_mode(telemetry::AnalysisMode::Static);
8246
8247 assert_eq!(fallback_failure_reason_for(&run, ExitCode::SUCCESS), None);
8248 assert_eq!(fallback_failure_reason_for(&run, ExitCode::from(1)), None);
8249 }
8250
8251 #[test]
8252 fn fallback_failure_reason_classifies_network_auth_and_analysis() {
8253 let static_run = telemetry_run_for_mode(telemetry::AnalysisMode::Static);
8254 let cloud_run = telemetry_run_for_mode(telemetry::AnalysisMode::ProductionCoverage);
8255
8256 assert_eq!(
8257 fallback_failure_reason_for(&static_run, ExitCode::from(api::NETWORK_EXIT_CODE)),
8258 Some(telemetry::FailureReason::Network),
8259 );
8260 assert_eq!(
8261 fallback_failure_reason_for(&static_run, ExitCode::from(12)),
8262 Some(telemetry::FailureReason::Auth),
8263 );
8264 assert_eq!(
8265 fallback_failure_reason_for(&cloud_run, ExitCode::from(3)),
8266 Some(telemetry::FailureReason::Auth),
8267 );
8268 assert_eq!(
8269 fallback_failure_reason_for(&static_run, ExitCode::from(2)),
8270 Some(telemetry::FailureReason::Analysis),
8271 );
8272 }
8273
8274 #[test]
8275 fn bare_coverage_flags_parse_without_subcommand() {
8276 let cli = Cli::try_parse_from([
8277 "fallow",
8278 "--coverage",
8279 "coverage/coverage-final.json",
8280 "--coverage-root",
8281 "/ci/workspace",
8282 ])
8283 .expect("bare combined coverage flags should parse");
8284 assert!(cli.command.is_none());
8285 assert_eq!(
8286 cli.coverage.as_deref(),
8287 Some(std::path::Path::new("coverage/coverage-final.json"))
8288 );
8289 assert_eq!(
8290 cli.coverage_root.as_deref(),
8291 Some(std::path::Path::new("/ci/workspace"))
8292 );
8293 }
8294
8295 #[test]
8296 fn bare_coverage_before_subcommand_is_detectable() {
8297 let cli = Cli::try_parse_from([
8298 "fallow",
8299 "--coverage",
8300 "coverage/coverage-final.json",
8301 "dead-code",
8302 ])
8303 .expect("clap should parse pre-subcommand bare coverage for custom rejection");
8304 assert!(cli.command.is_some());
8305 assert!(cli_has_bare_coverage_input(&cli));
8306 let message = bare_coverage_subcommand_error_message();
8307 assert!(message.contains("bare combined-mode flags"));
8308 assert!(message.contains("fallow health --coverage <coverage-final.json>"));
8309 }
8310
8311 #[test]
8312 fn bare_combined_baseline_before_subcommand_is_detectable() {
8313 for flag in ["--dupes-baseline", "--health-baseline"] {
8314 let cli = Cli::try_parse_from(["fallow", flag, "x.json", "dead-code"])
8315 .expect("clap should parse a pre-subcommand combined baseline");
8316 assert!(cli.command.is_some());
8317 assert_eq!(cli_bare_combined_baseline_flag(&cli), Some(flag));
8318 let message = bare_combined_baseline_subcommand_error_message(flag);
8319 assert!(message.contains(flag));
8320 assert!(message.contains("omit the subcommand"));
8321 }
8322 let bare = Cli::try_parse_from(["fallow", "--dupes-baseline", "x.json"])
8323 .expect("bare combined baseline should parse");
8324 assert!(bare.command.is_none());
8325 }
8326
8327 #[test]
8328 fn subcommand_coverage_flag_keeps_regular_clap_error() {
8329 let Err(err) = Cli::try_parse_from(["fallow", "dead-code", "--coverage"]) else {
8330 panic!("dead-code --coverage should fail to parse");
8331 };
8332 assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
8333 }
8334
8335 #[test]
8336 fn type_aware_flags_parse_for_semantic_analysis() {
8337 let cli = Cli::try_parse_from([
8338 "fallow",
8339 "dead-code",
8340 "--unused-class-members",
8341 "--type-aware",
8342 "--type-aware-project",
8343 "tsconfig.json",
8344 "--type-aware-project",
8345 "packages/web/tsconfig.json",
8346 ])
8347 .expect("type-aware flag should parse");
8348 assert!(cli.type_aware);
8349 assert_eq!(
8350 cli.type_aware_project,
8351 [
8352 PathBuf::from("tsconfig.json"),
8353 PathBuf::from("packages/web/tsconfig.json")
8354 ]
8355 );
8356 let Some(Command::Check {
8357 unused_class_members,
8358 ..
8359 }) = cli.command
8360 else {
8361 panic!("dead-code should parse as the check command");
8362 };
8363 assert!(unused_class_members);
8364 }
8365
8366 #[test]
8367 fn no_type_aware_conflicts_with_type_aware() {
8368 let Err(err) = Cli::try_parse_from(["fallow", "audit", "--type-aware", "--no-type-aware"])
8369 else {
8370 panic!("--no-type-aware must conflict with --type-aware");
8371 };
8372 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
8373 }
8374
8375 #[test]
8376 fn no_type_aware_forces_semantic_analysis_off() {
8377 let cli = Cli::try_parse_from(["fallow", "audit", "--no-type-aware"])
8378 .expect("--no-type-aware should parse on audit");
8379 assert_eq!(cli.type_aware_override(), Some(false));
8380
8381 let cli = Cli::try_parse_from(["fallow", "dead-code", "--type-aware"])
8382 .expect("--type-aware should parse");
8383 assert_eq!(cli.type_aware_override(), Some(true));
8384
8385 let cli = Cli::try_parse_from(["fallow", "dead-code"]).expect("bare command should parse");
8386 assert_eq!(cli.type_aware_override(), None);
8387 }
8388
8389 #[test]
8390 fn type_aware_status_output_hides_host_paths() {
8391 let root = Path::new("/private/work/project");
8392 let output = type_aware_status_output(
8393 root,
8394 fallow_api::TypeAwareStatus {
8395 available: false,
8396 discovery_source: Some("environment-override"),
8397 companion_path: Some(PathBuf::from("/private/tools/fallow-type-aware")),
8398 package_version: None,
8399 protocol_version: 7,
8400 backend_family: None,
8401 backend_version: None,
8402 remediation: Some(
8403 "failed to launch /private/tools/fallow-type-aware from /private/work/project"
8404 .to_string(),
8405 ),
8406 },
8407 );
8408
8409 assert_eq!(
8410 output.schema_version.0,
8411 fallow_output::TYPE_AWARE_STATUS_SCHEMA_VERSION
8412 );
8413 assert_eq!(output.companion_path.as_deref(), Some("fallow-type-aware"));
8414 let remediation = output.remediation.expect("remediation");
8415 assert!(!remediation.contains("/private/"));
8416 assert!(remediation.contains("fallow-type-aware"));
8417 }
8418
8419 #[test]
8420 fn format_parsing_covers_all_variants() {
8421 assert!(matches!(parse_format_arg("json"), Some(Format::Json)));
8422 assert!(matches!(parse_format_arg("JSON"), Some(Format::Json)));
8423 assert!(matches!(parse_format_arg("human"), Some(Format::Human)));
8424 assert!(matches!(parse_format_arg("sarif"), Some(Format::Sarif)));
8425 assert!(matches!(parse_format_arg("compact"), Some(Format::Compact)));
8426 assert!(matches!(
8427 parse_format_arg("markdown"),
8428 Some(Format::Markdown)
8429 ));
8430 assert!(matches!(parse_format_arg("md"), Some(Format::Markdown)));
8431 assert!(matches!(
8432 parse_format_arg("codeclimate"),
8433 Some(Format::CodeClimate)
8434 ));
8435 assert!(matches!(
8436 parse_format_arg("gitlab-codequality"),
8437 Some(Format::CodeClimate)
8438 ));
8439 assert!(matches!(
8440 parse_format_arg("gitlab-code-quality"),
8441 Some(Format::CodeClimate)
8442 ));
8443 assert!(matches!(
8444 parse_format_arg("pr-comment-github"),
8445 Some(Format::PrCommentGithub)
8446 ));
8447 assert!(matches!(
8448 parse_format_arg("pr-comment-gitlab"),
8449 Some(Format::PrCommentGitlab)
8450 ));
8451 assert!(matches!(
8452 parse_format_arg("review-github"),
8453 Some(Format::ReviewGithub)
8454 ));
8455 assert!(matches!(
8456 parse_format_arg("review-gitlab"),
8457 Some(Format::ReviewGitlab)
8458 ));
8459 assert!(matches!(parse_format_arg("badge"), Some(Format::Badge)));
8460 assert!(parse_format_arg("xml").is_none());
8461 assert!(parse_format_arg("").is_none());
8462 }
8463
8464 #[test]
8465 fn quiet_parsing_logic() {
8466 let parse = |s: &str| -> bool { s == "1" || s.eq_ignore_ascii_case("true") };
8467 assert!(parse("1"));
8468 assert!(parse("true"));
8469 assert!(parse("TRUE"));
8470 assert!(parse("True"));
8471 assert!(!parse("0"));
8472 assert!(!parse("false"));
8473 assert!(!parse("yes"));
8474 }
8475
8476 #[test]
8477 fn tracing_filter_defaults_to_warn_without_env() {
8478 assert_eq!(build_tracing_filter(None).to_string(), "warn");
8479 }
8480
8481 #[test]
8482 fn tracing_filter_respects_explicit_env_directives() {
8483 assert_eq!(build_tracing_filter(Some("info")).to_string(), "info");
8484 }
8485
8486 #[test]
8487 fn tracing_filter_treats_empty_env_as_off() {
8488 assert_eq!(build_tracing_filter(Some("")).to_string(), "off");
8489 assert_eq!(build_tracing_filter(Some(" ")).to_string(), "off");
8490 }
8491}