1#![expect(
2 clippy::print_stdout,
3 clippy::print_stderr,
4 reason = "CLI binary produces intentional terminal output"
5)]
6#![cfg_attr(
7 test,
8 allow(
9 clippy::unwrap_used,
10 clippy::expect_used,
11 reason = "tests use unwrap and expect to keep fixture setup concise"
12 )
13)]
14
15use std::io::IsTerminal as _;
16use std::path::{Path, PathBuf};
17use std::process::ExitCode;
18
19use clap::{Parser, Subcommand};
20
21mod api;
22#[cfg(test)]
23mod architecture_boundaries;
24mod audit;
25mod audit_brief;
26mod audit_cache_prune;
27mod audit_decision_surface;
28mod audit_focus;
29mod audit_walkthrough;
30mod base_worktree;
31pub use base_worktree::canonical_root_hash;
35mod walkthrough_state;
36use fallow_engine::baseline;
37mod agent_install;
38mod cache_notice;
39mod check;
40mod ci;
41mod ci_template;
42mod cli_agent;
43mod cli_format;
44mod cli_hooks;
45mod cli_impact;
46mod cli_production;
47mod cli_report;
48mod cli_startup;
49pub use fallow_engine::codeowners;
50mod combined;
51mod config;
52mod coverage;
53mod dupes;
54mod exit_codes;
55pub mod explain;
56mod fix;
57mod flags;
58mod guard;
59mod health;
60mod impact;
61mod init;
62mod inspect;
63mod json_style;
64mod license;
65mod list;
66mod migrate;
67mod onboarding;
68#[cfg(test)]
69mod output_envelope;
70mod output_runtime;
71mod path_util;
72mod plugin_check;
73mod rayon_pool;
74mod regression;
75pub mod report;
76mod rule_pack;
77mod runtime_support;
78mod schema;
79mod security;
80mod security_help;
81mod setup_hooks;
82mod signal;
83mod similar_code_cli;
84mod similar_code_help;
85mod suppressions;
86mod task_matrix;
87mod telemetry;
88mod trace_chain;
89mod update_check;
90use fallow_engine::validate;
91use fallow_engine::vital_signs;
92mod cli_telemetry;
93mod viz;
94mod watch;
95
96use check::{CheckOptions, IssueFilters, TraceOptions};
97pub(crate) mod error;
99use cli_agent::{AgentCli, run_agent_command};
100#[cfg(test)]
101use cli_format::parse_format_arg;
102use cli_format::{Format, FormatConfig};
103use cli_hooks::{HooksCli, run_hooks_command};
104use cli_impact::{ImpactCli, ImpactCrossRepoOpts, ImpactSortCli, dispatch_impact};
105use cli_production::{ProductionModes, resolve_production_modes};
106#[cfg(test)]
107use cli_startup::build_tracing_filter;
108use cli_startup::{
109 bare_coverage_subcommand_error_message, cli_has_bare_coverage_input, parse_cli_args,
110 run_pre_dispatch_checks, setup_tracing, validate_inputs,
111};
112#[cfg(test)]
113use cli_telemetry::TelemetryRun;
114#[cfg(test)]
115use cli_telemetry::{fallback_failure_reason_for, telemetry_workflow_for_command};
116use cli_telemetry::{record_run_epilogue, start_telemetry_run};
117use dupes::{DupesMode, DupesOptions};
118use error::emit_error;
119use health::{HealthOptions, SortBy};
120use list::ListOptions;
121pub(crate) use runtime_support::{AnalysisKind, GroupBy};
122pub(crate) use runtime_support::{
123 ConfigLoadOptions, LoadConfigArgs, build_ownership_resolver, load_config,
124 load_config_for_analysis,
125};
126#[cfg(test)]
127use security_help::{SECURITY_UNSUPPORTED_GLOBAL_LONGS, SecurityHelpTarget};
128use security_help::{render_security_help, security_help_target};
129use similar_code_help::{render_similar_code_help, similar_code_help_target};
130
131const DEFAULT_MIN_INVOCATIONS_HOT: u64 = 100;
132
133const TOP_LEVEL_HELP_TEMPLATE: &str =
134 "{about-with-newline}\n{usage-heading} {usage}{after-help}\n\nOptions:\n{options}";
135
136macro_rules! top_level_task_cheat_sheet {
139 () => {
140 "\
141When the agent is about to...
142 delete an \"unused\" export or file fallow dead-code --trace <file>:<export>
143 prove exact TypeScript symbol consumers fallow dead-code --type-aware --symbol-impact <file>:<export-or-class.method>
144 delete an \"unused\" dependency fallow dead-code --trace-dependency <name>
145 commit or open a PR fallow audit --base <ref>
146 prioritize refactoring fallow health --hotspots --targets
147 ask who owns code fallow health --ownership
148 check untested-but-reachable code fallow health --coverage-gaps
149 consolidate duplication fallow dupes --trace dup:<fingerprint>
150 find feature flags fallow flags
151 check architecture rules before editing fallow guard <files>
152 surface security candidates fallow security
153 inspect a target before editing fallow inspect --file <path>
154 understand a finding fallow explain <issue-type>
155 scope a monorepo --workspace <glob> / --changed-workspaces <ref>"
156 };
157}
158
159macro_rules! top_level_core_command_groups {
160 () => {
161 "\
162Analysis:
163 dead-code Analyze unused code, dependency hygiene, and architecture cycles
164 dupes Find copy-paste and structural code duplication
165 health Analyze complexity, maintainability, hotspots, and coverage gaps
166 flags Detect feature flag usage patterns
167 security Surface local security candidates for agent verification (opt-in)
168 similar-code Find semantic implementation overlap for verification (opt-in, local)
169 audit Review changed files for dead code, complexity, duplication, and styling
170
171Workflow:
172 watch Re-run analysis as files change
173 fix Auto-fix safe unused-code findings"
174 };
175}
176
177macro_rules! top_level_extended_command_groups {
178 () => {
179 "\
180Project inspection:
181 list List discovered files, entry points, plugins, boundaries, and workspaces
182 inspect Inspect one file or exported symbol as a bundled evidence query
183 trace Trace a symbol's call chain (best-effort, syntactic)
184 guard Show which architecture rules apply to files before editing
185 decision-surface Surface the structural decisions a change embeds (advisory)
186 workspaces Show monorepo workspace discovery diagnostics
187 explain Explain one issue type without running analysis
188 suppressions List active fallow-ignore suppression markers
189 impact Show what fallow has done for you (opt-in, local-only)
190 viz Generate an interactive HTML map of the codebase
191
192Setup and configuration:
193 init Create a fallow config, optionally with a Git hook
194 agent Wire fallow into Claude Code, Codex, or Cursor in one pass
195 audit-cache Maintain reusable audit base-snapshot caches
196 recommend Recommend a project-tailored config for an agent to author
197 migrate Migrate knip, jscpd, or stylelint config to fallow
198 config Show the resolved config and loaded config file
199 config-schema Print the fallow config JSON Schema
200 plugin-schema Print the external plugin JSON Schema
201 plugin-check Dry-run external plugins and report what they seed
202 rule-pack Manage declarative rule packs (policy-as-code)
203 rule-pack-schema Print the rule pack JSON Schema
204 type-aware Inspect the optional TypeScript semantic companion
205
206Automation and CI:
207 ci Build PR/MR feedback envelopes
208 ci-template Print or vendor CI integration templates
209 report Re-render saved JSON as GitHub or CodeClimate output
210 hooks Install or remove fallow-managed Git and agent hooks
211 setup-hooks Deprecated: use `agent install` or `hooks install --target agent`
212
213Runtime coverage:
214 coverage Set up or analyze runtime coverage data
215 license Manage the paid-feature license
216 telemetry Manage opt-in product telemetry
217
218Reference:
219 schema Dump the CLI interface as machine-readable JSON
220 help Print this message or the help of a command"
221 };
222}
223
224const TOP_LEVEL_AFTER_HELP: &str = concat!(
225 top_level_task_cheat_sheet!(),
226 "\n\n",
227 top_level_core_command_groups!(),
228 "\n\nRun fallow --help for the complete command list."
229);
230
231const TOP_LEVEL_AFTER_LONG_HELP: &str = concat!(
232 top_level_task_cheat_sheet!(),
233 "\n\n",
234 top_level_core_command_groups!(),
235 "\n\n",
236 top_level_extended_command_groups!(),
237 "\n\n",
238 "When no command is given, fallow runs dead-code + dupes + health together.\n",
239 "Use --only/--skip to select specific analyses."
240);
241
242#[derive(Parser)]
243#[command(
244 name = "fallow",
245 about = "Codebase analyzer for TypeScript/JavaScript: unused code, circular dependencies, code duplication, complexity hotspots, and architecture boundary violations",
246 version,
247 disable_version_flag = true,
248 help_template = TOP_LEVEL_HELP_TEMPLATE,
249 after_help = TOP_LEVEL_AFTER_HELP,
250 after_long_help = TOP_LEVEL_AFTER_LONG_HELP
251)]
252struct Cli {
253 #[command(subcommand)]
254 command: Option<Command>,
255
256 #[arg(
260 short = 'v',
261 visible_short_alias = 'V',
262 long = "version",
263 action = clap::ArgAction::Version
264 )]
265 version: Option<bool>,
266
267 #[arg(short, long, global = true)]
269 root: Option<PathBuf>,
270
271 #[arg(short, long, global = true)]
273 config: Option<PathBuf>,
274
275 #[arg(hide_short_help = true, long, global = true)]
277 allow_remote_extends: bool,
278
279 #[arg(
281 short,
282 long,
283 visible_alias = "output",
284 global = true,
285 default_value = "human"
286 )]
287 format: Format,
288
289 #[arg(hide_short_help = true, long, global = true)]
291 pretty: bool,
292
293 #[arg(short, long, global = true)]
295 quiet: bool,
296
297 #[arg(hide_short_help = true, long, global = true)]
299 no_cache: bool,
300
301 #[arg(hide_short_help = true, long, global = true)]
303 threads: Option<usize>,
304
305 #[arg(long, visible_alias = "base", global = true)]
307 changed_since: Option<String>,
308
309 #[arg(
314 hide_short_help = true,
315 long = "diff-file",
316 value_name = "PATH",
317 global = true
318 )]
319 diff_file: Option<PathBuf>,
320
321 #[arg(hide_short_help = true, long = "diff-stdin", global = true)]
324 diff_stdin: bool,
325
326 #[arg(
333 hide_short_help = true,
334 long = "churn-file",
335 value_name = "PATH",
336 global = true
337 )]
338 churn_file: Option<PathBuf>,
339
340 #[arg(
347 hide_short_help = true,
348 long = "max-file-size",
349 value_name = "MB",
350 global = true
351 )]
352 max_file_size: Option<u32>,
353
354 #[arg(hide_short_help = true, long, global = true)]
356 baseline: Option<PathBuf>,
357
358 #[arg(
373 hide_short_help = true,
374 long = "baseline-mode",
375 value_enum,
376 global = true
377 )]
378 baseline_mode: Option<BaselineModeArg>,
379
380 #[arg(long, global = true, value_name = "RUN_ID", hide = true)]
386 parent_run: Option<String>,
387
388 #[arg(hide_short_help = true, long, global = true)]
390 save_baseline: Option<PathBuf>,
391
392 #[arg(long, global = true)]
395 production: bool,
396
397 #[arg(
401 hide_short_help = true,
402 long = "no-production",
403 global = true,
404 conflicts_with = "production"
405 )]
406 no_production: bool,
407
408 #[arg(hide_short_help = true, long = "production-dead-code")]
410 production_dead_code: bool,
411
412 #[arg(hide_short_help = true, long = "production-health")]
414 production_health: bool,
415
416 #[arg(hide_short_help = true, long = "production-dupes")]
418 production_dupes: bool,
419
420 #[arg(short, long, global = true, value_delimiter = ',')]
424 workspace: Option<Vec<String>>,
425
426 #[arg(long, global = true, value_name = "REF")]
429 changed_workspaces: Option<String>,
430
431 #[arg(hide_short_help = true, long, global = true)]
433 group_by: Option<GroupBy>,
434
435 #[arg(hide_short_help = true, long, global = true)]
437 performance: bool,
438
439 #[arg(hide_short_help = true, long, global = true)]
441 explain: bool,
442
443 #[arg(hide_short_help = true, long, global = true)]
445 explain_skipped: bool,
446
447 #[arg(hide_short_help = true, long, global = true)]
449 summary: bool,
450
451 #[arg(long, global = true)]
453 ci: bool,
454
455 #[arg(hide_short_help = true, long, global = true)]
457 fail_on_issues: bool,
458
459 #[arg(hide_short_help = true, long, global = true, value_name = "PATH")]
461 sarif_file: Option<PathBuf>,
462
463 #[arg(short = 'o', long, global = true, value_name = "PATH")]
467 output_file: Option<PathBuf>,
468
469 #[arg(
479 hide_short_help = true,
480 long = "report-path-prefix",
481 visible_alias = "annotations-path-prefix",
482 global = true,
483 value_name = "PREFIX"
484 )]
485 report_path_prefix: Option<String>,
486
487 #[arg(hide_short_help = true, long, global = true)]
489 fail_on_regression: bool,
490
491 #[arg(
493 hide_short_help = true,
494 long,
495 global = true,
496 value_name = "TOLERANCE",
497 default_value = "0"
498 )]
499 tolerance: String,
500
501 #[arg(hide_short_help = true, long, global = true, value_name = "PATH")]
503 regression_baseline: Option<PathBuf>,
504
505 #[expect(
509 clippy::option_option,
510 reason = "clap pattern: None=not passed, Some(None)=flag only (write to config), Some(Some(path))=write to file"
511 )]
512 #[arg(hide_short_help = true, long, global = true, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
513 save_regression_baseline: Option<Option<String>>,
514
515 #[arg(long, value_delimiter = ',')]
517 only: Vec<AnalysisKind>,
518
519 #[arg(long, value_delimiter = ',')]
521 skip: Vec<AnalysisKind>,
522
523 #[arg(hide_short_help = true, long = "dupes-mode", global = true)]
525 dupes_mode: Option<DupesMode>,
526
527 #[arg(hide_short_help = true, long = "dupes-near", global = true)]
529 dupes_near: bool,
530
531 #[arg(hide_short_help = true, long = "dupes-threshold", global = true)]
533 dupes_threshold: Option<f64>,
534
535 #[arg(hide_short_help = true, long = "dupes-min-tokens", global = true)]
537 dupes_min_tokens: Option<usize>,
538
539 #[arg(hide_short_help = true, long = "dupes-min-lines", global = true)]
541 dupes_min_lines: Option<usize>,
542
543 #[arg(hide_short_help = true, long = "dupes-min-occurrences", global = true, value_parser = parse_min_occurrences)]
545 dupes_min_occurrences: Option<usize>,
546
547 #[arg(hide_short_help = true, long = "dupes-skip-local", global = true)]
549 dupes_skip_local: bool,
550
551 #[arg(hide_short_help = true, long = "dupes-cross-language", global = true)]
553 dupes_cross_language: bool,
554
555 #[arg(hide_short_help = true, long = "dupes-ignore-imports", global = true)]
558 dupes_ignore_imports: bool,
559
560 #[arg(
563 hide_short_help = true,
564 long = "dupes-no-ignore-imports",
565 global = true,
566 conflicts_with = "dupes_ignore_imports"
567 )]
568 dupes_no_ignore_imports: bool,
569
570 #[arg(hide_short_help = true, long)]
572 score: bool,
573
574 #[arg(hide_short_help = true, long)]
576 trend: bool,
577
578 #[expect(
581 clippy::option_option,
582 reason = "clap pattern: None=not passed, Some(None)=default path, Some(Some(path))=custom path"
583 )]
584 #[arg(hide_short_help = true, long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
585 save_snapshot: Option<Option<String>>,
586
587 #[arg(hide_short_help = true, long, value_name = "PATH")]
590 coverage: Option<PathBuf>,
591
592 #[arg(hide_short_help = true, long = "coverage-root", value_name = "PATH")]
595 coverage_root: Option<PathBuf>,
596
597 #[arg(hide_short_help = true, long, global = true)]
599 include_entry_exports: bool,
600
601 #[arg(hide_short_help = true, long, global = true)]
604 type_aware: bool,
605
606 #[arg(
609 hide_short_help = true,
610 long,
611 global = true,
612 conflicts_with = "type_aware"
613 )]
614 no_type_aware: bool,
615
616 #[arg(hide_short_help = true, long, global = true, value_name = "PATH", action = clap::ArgAction::Append)]
618 type_aware_project: Vec<PathBuf>,
619
620 #[arg(hide_short_help = true, long, global = true, value_enum)]
622 type_aware_require: Option<TypeAwareRequireArg>,
623}
624
625impl Cli {
626 const fn type_aware_override(&self) -> Option<bool> {
630 if self.no_type_aware {
631 Some(false)
632 } else if self.type_aware {
633 Some(true)
634 } else {
635 None
636 }
637 }
638}
639
640#[derive(Clone, Copy, Subcommand)]
641enum TypeAwareCli {
642 Status,
644}
645
646#[derive(Subcommand)]
647enum Command {
648 #[command(name = "dead-code", alias = "check")]
650 Check {
651 #[arg(long)]
653 unused_files: bool,
654
655 #[arg(long)]
657 unused_exports: bool,
658
659 #[arg(long)]
661 unused_deps: bool,
662
663 #[arg(long)]
665 unused_types: bool,
666
667 #[arg(long)]
669 private_type_leaks: bool,
670
671 #[arg(long)]
673 unused_enum_members: bool,
674
675 #[arg(long)]
677 unused_class_members: bool,
678
679 #[arg(long)]
681 unused_store_members: bool,
682
683 #[arg(long)]
685 unprovided_injects: bool,
686
687 #[arg(long)]
689 unrendered_components: bool,
690
691 #[arg(long)]
693 unused_component_props: bool,
694
695 #[arg(long)]
697 unused_component_emits: bool,
698
699 #[arg(long)]
701 unused_component_inputs: bool,
702
703 #[arg(long)]
705 unused_component_outputs: bool,
706
707 #[arg(long)]
709 unused_svelte_events: bool,
710
711 #[arg(long)]
713 unused_server_actions: bool,
714
715 #[arg(long)]
717 unused_load_data_keys: bool,
718
719 #[arg(long)]
721 unresolved_imports: bool,
722
723 #[arg(long)]
725 unlisted_deps: bool,
726
727 #[arg(long)]
729 duplicate_exports: bool,
730
731 #[arg(long)]
733 circular_deps: bool,
734
735 #[arg(long)]
737 re_export_cycles: bool,
738
739 #[arg(long)]
741 boundary_violations: bool,
742
743 #[arg(long)]
745 policy_violations: bool,
746
747 #[arg(long)]
749 stale_suppressions: bool,
750
751 #[arg(long)]
753 unused_catalog_entries: bool,
754
755 #[arg(long)]
757 empty_catalog_groups: bool,
758
759 #[arg(long)]
761 unresolved_catalog_references: bool,
762
763 #[arg(long)]
765 unused_dependency_overrides: bool,
766
767 #[arg(long)]
769 misconfigured_dependency_overrides: bool,
770
771 #[arg(long)]
773 include_dupes: bool,
774
775 #[arg(long, value_name = "FILE:EXPORT")]
777 trace: Option<String>,
778
779 #[arg(long, value_name = "PATH")]
781 trace_file: Option<String>,
782
783 #[arg(long, value_name = "PACKAGE")]
785 trace_dependency: Option<String>,
786
787 #[arg(long, value_name = "PATH")]
791 impact_closure: Option<String>,
792
793 #[arg(long, value_name = "FILE:EXPORT")]
795 symbol_impact: Option<String>,
796
797 #[arg(long)]
799 top: Option<usize>,
800
801 #[arg(long, value_name = "PATH")]
805 file: Vec<std::path::PathBuf>,
806 },
807
808 Watch {
810 #[arg(long)]
812 no_clear: bool,
813 },
814
815 TypeAware {
817 #[command(subcommand)]
818 subcommand: TypeAwareCli,
819 },
820
821 SimilarCode {
827 #[command(subcommand)]
828 subcommand: Option<similar_code_cli::SimilarCodeSubcommand>,
829 #[arg(long, value_name = "0..1")]
831 threshold: Option<f64>,
832 #[arg(long, value_name = "N")]
834 min_lines: Option<usize>,
835 #[arg(long, value_name = "N")]
837 top: Option<usize>,
838 #[arg(long, value_name = "PATH")]
840 file: Vec<PathBuf>,
841 },
842
843 Inspect {
845 #[arg(
847 long,
848 value_name = "PATH",
849 conflicts_with = "symbol",
850 required_unless_present = "symbol"
851 )]
852 file: Option<String>,
853
854 #[arg(long, value_name = "FILE:EXPORT", conflicts_with = "file")]
856 symbol: Option<String>,
857
858 #[arg(long)]
863 symbol_chain: bool,
864
865 #[arg(long)]
868 churn: bool,
869 },
870
871 Trace {
880 #[arg(value_name = "FILE:SYMBOL")]
882 symbol: String,
883
884 #[arg(long)]
887 callers: bool,
888
889 #[arg(long)]
892 callees: bool,
893
894 #[arg(long, value_name = "N")]
897 depth: Option<u32>,
898 },
899
900 Fix {
915 #[arg(long)]
917 dry_run: bool,
918
919 #[arg(long, alias = "force")]
921 yes: bool,
922
923 #[arg(long)]
930 no_create_config: bool,
931 },
932
933 Init {
942 #[arg(long)]
944 toml: bool,
945
946 #[arg(long, conflicts_with_all = ["toml", "hooks", "branch"])]
948 agents: bool,
949
950 #[arg(long)]
954 hooks: bool,
955
956 #[arg(long, requires = "hooks")]
958 branch: Option<String>,
959
960 #[arg(long, conflicts_with_all = ["toml", "agents", "hooks", "branch"])]
964 decline: bool,
965 },
966
967 Hooks {
974 #[command(subcommand)]
975 subcommand: HooksCli,
976 },
977
978 Agent {
984 #[command(subcommand)]
985 subcommand: AgentCli,
986 },
987
988 Ci {
990 #[command(subcommand)]
991 subcommand: CiCli,
992 },
993
994 ConfigSchema,
996
997 PluginSchema,
999
1000 PluginCheck,
1002
1003 RulePackSchema,
1005
1006 RulePack {
1008 #[command(subcommand)]
1009 subcommand: RulePackCli,
1010 },
1011
1012 Guard {
1014 #[arg(required = true, num_args = 1..)]
1016 files: Vec<String>,
1017 },
1018
1019 Config {
1037 #[arg(long)]
1039 path: bool,
1040 },
1041
1042 Recommend,
1050
1051 List {
1053 #[arg(long)]
1055 entry_points: bool,
1056
1057 #[arg(long)]
1059 files: bool,
1060
1061 #[arg(long)]
1063 plugins: bool,
1064
1065 #[arg(long)]
1067 boundaries: bool,
1068
1069 #[arg(long)]
1073 workspaces: bool,
1074 },
1075
1076 Workspaces,
1082
1083 Dupes {
1085 #[arg(long)]
1088 mode: Option<DupesMode>,
1089
1090 #[arg(long)]
1092 near: bool,
1093
1094 #[arg(long)]
1097 min_tokens: Option<usize>,
1098
1099 #[arg(long)]
1102 min_lines: Option<usize>,
1103
1104 #[arg(long, value_parser = parse_min_occurrences)]
1109 min_occurrences: Option<usize>,
1110
1111 #[arg(long)]
1114 threshold: Option<f64>,
1115
1116 #[arg(long)]
1118 skip_local: bool,
1119
1120 #[arg(long)]
1122 cross_language: bool,
1123
1124 #[arg(long)]
1128 ignore_imports: bool,
1129
1130 #[arg(long, conflicts_with = "ignore_imports")]
1133 no_ignore_imports: bool,
1134
1135 #[arg(long)]
1138 top: Option<usize>,
1139
1140 #[arg(long, value_name = "FILE:LINE")]
1142 trace: Option<String>,
1143 },
1144
1145 Health {
1151 #[arg(long)]
1153 max_cyclomatic: Option<u16>,
1154
1155 #[arg(long)]
1157 max_cognitive: Option<u16>,
1158
1159 #[arg(long)]
1163 max_crap: Option<f64>,
1164
1165 #[arg(long)]
1167 top: Option<usize>,
1168
1169 #[arg(long, default_value = "cyclomatic")]
1171 sort: SortBy,
1172
1173 #[arg(long)]
1176 complexity: bool,
1177
1178 #[arg(long)]
1185 complexity_breakdown: bool,
1186
1187 #[arg(long)]
1192 file_scores: bool,
1193
1194 #[arg(long)]
1197 coverage_gaps: bool,
1198
1199 #[arg(long)]
1202 hotspots: bool,
1203
1204 #[arg(long)]
1208 ownership: bool,
1209
1210 #[arg(long, value_name = "MODE", value_enum)]
1215 ownership_emails: Option<EmailModeArg>,
1216
1217 #[arg(long)]
1220 targets: bool,
1221
1222 #[arg(long)]
1225 type_coupling: bool,
1226
1227 #[arg(long)]
1232 css: bool,
1233
1234 #[arg(long, value_enum)]
1237 effort: Option<EffortFilter>,
1238
1239 #[arg(long)]
1242 score: bool,
1243
1244 #[arg(long, value_name = "N")]
1253 min_score: Option<f64>,
1254
1255 #[arg(long, value_name = "LEVEL", value_enum)]
1259 min_severity: Option<HealthSeverityCli>,
1260
1261 #[arg(long)]
1265 report_only: bool,
1266
1267 #[arg(long, value_name = "DURATION")]
1270 since: Option<String>,
1271
1272 #[arg(long, value_name = "N")]
1274 min_commits: Option<u32>,
1275
1276 #[expect(
1280 clippy::option_option,
1281 reason = "clap pattern: None=not passed, Some(None)=flag only, Some(Some(path))=with value"
1282 )]
1283 #[arg(long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
1284 save_snapshot: Option<Option<String>>,
1285
1286 #[arg(long)]
1290 trend: bool,
1291
1292 #[arg(long, value_name = "PATH")]
1301 coverage: Option<PathBuf>,
1302
1303 #[arg(long, value_name = "PATH")]
1309 coverage_root: Option<PathBuf>,
1310
1311 #[arg(long, value_name = "PATH")]
1315 runtime_coverage: Option<PathBuf>,
1316
1317 #[arg(long, default_value_t = 100)]
1319 min_invocations_hot: u64,
1320
1321 #[arg(long, value_name = "N")]
1327 min_observation_volume: Option<u32>,
1328
1329 #[arg(long, value_name = "RATIO")]
1334 low_traffic_threshold: Option<f64>,
1335 },
1336
1337 Flags {
1344 #[arg(long)]
1346 top: Option<usize>,
1347 },
1348
1349 Suppressions {
1359 #[arg(long, value_name = "PATH")]
1361 file: Vec<std::path::PathBuf>,
1362 },
1363
1364 Explain {
1370 #[arg(required = true, num_args = 1.., value_name = "ISSUE_TYPE")]
1372 issue_type: Vec<String>,
1373 },
1374
1375 #[command(visible_alias = "review")]
1400 Audit {
1401 #[arg(long = "production-dead-code")]
1403 production_dead_code: bool,
1404
1405 #[arg(long = "production-health")]
1407 production_health: bool,
1408
1409 #[arg(long = "production-dupes")]
1411 production_dupes: bool,
1412
1413 #[arg(long)]
1416 dead_code_baseline: Option<PathBuf>,
1417
1418 #[arg(long)]
1421 health_baseline: Option<PathBuf>,
1422
1423 #[arg(long)]
1426 dupes_baseline: Option<PathBuf>,
1427
1428 #[arg(long)]
1432 max_crap: Option<f64>,
1433
1434 #[arg(long, value_name = "PATH")]
1438 coverage: Option<PathBuf>,
1439
1440 #[arg(long, value_name = "PATH")]
1444 coverage_root: Option<PathBuf>,
1445
1446 #[arg(long = "no-css")]
1448 no_css: bool,
1449
1450 #[arg(long)]
1454 css_deep: bool,
1455
1456 #[arg(long = "no-css-deep")]
1458 no_css_deep: bool,
1459
1460 #[arg(long, value_enum)]
1466 gate: Option<AuditGateArg>,
1467
1468 #[arg(long, value_name = "PATH")]
1477 runtime_coverage: Option<PathBuf>,
1478
1479 #[arg(long, default_value_t = 100)]
1482 min_invocations_hot: u64,
1483
1484 #[arg(long, value_name = "MARKER", hide = true)]
1489 gate_marker: Option<String>,
1490
1491 #[arg(long)]
1497 brief: bool,
1498
1499 #[arg(
1504 long,
1505 value_name = "N",
1506 default_value_t = audit_decision_surface::DEFAULT_DECISION_CAP
1507 )]
1508 max_decisions: usize,
1509
1510 #[arg(long, conflicts_with_all = ["walkthrough_file", "walkthrough"])]
1518 walkthrough_guide: bool,
1519
1520 #[arg(long, value_name = "PATH")]
1528 walkthrough_file: Option<PathBuf>,
1529
1530 #[arg(long, conflicts_with_all = ["walkthrough_guide", "walkthrough_file"])]
1536 walkthrough: bool,
1537
1538 #[arg(long, value_name = "PATH")]
1544 mark_viewed: Vec<PathBuf>,
1545
1546 #[arg(long)]
1550 show_cleared: bool,
1551
1552 #[arg(long)]
1558 show_deprioritized: bool,
1559 },
1560
1561 AuditCache {
1563 #[command(subcommand)]
1564 subcommand: AuditCacheCli,
1565 },
1566
1567 DecisionSurface {
1579 #[arg(
1582 long,
1583 value_name = "N",
1584 default_value_t = audit_decision_surface::DEFAULT_DECISION_CAP
1585 )]
1586 max_decisions: usize,
1587 },
1588
1589 Impact {
1599 #[command(subcommand)]
1600 subcommand: Option<ImpactCli>,
1601 #[arg(long)]
1605 all: bool,
1606 #[arg(long, value_enum, default_value_t = ImpactSortCli::Recent)]
1608 sort: ImpactSortCli,
1609 #[arg(long)]
1612 limit: Option<usize>,
1613 },
1614
1615 Security {
1646 #[command(subcommand)]
1647 subcommand: Option<SecuritySubcommand>,
1648 #[arg(long, value_name = "PATH")]
1653 runtime_coverage: Option<PathBuf>,
1654 #[arg(long, default_value_t = 100)]
1657 min_invocations_hot: u64,
1658 #[arg(long, value_name = "PATH")]
1662 file: Vec<std::path::PathBuf>,
1663 #[arg(long, value_name = "MODE")]
1669 gate: Option<security::SecurityGateArg>,
1670 #[arg(long)]
1672 surface: bool,
1673 },
1674
1675 Report {
1680 #[arg(long, value_name = "PATH")]
1683 from: PathBuf,
1684 },
1685 Schema,
1687
1688 CiTemplate {
1695 #[command(subcommand)]
1696 subcommand: CiTemplateCli,
1697 },
1698
1699 Migrate {
1701 #[arg(long, conflicts_with = "jsonc")]
1703 toml: bool,
1704
1705 #[arg(long)]
1713 jsonc: bool,
1714
1715 #[arg(long)]
1717 dry_run: bool,
1718
1719 #[arg(long, value_name = "PATH")]
1721 from: Option<PathBuf>,
1722 },
1723
1724 License {
1731 #[command(subcommand)]
1732 subcommand: LicenseCli,
1733 },
1734
1735 Telemetry {
1743 #[command(subcommand)]
1744 subcommand: TelemetryCli,
1745 },
1746
1747 Coverage {
1753 #[command(subcommand)]
1754 subcommand: CoverageCli,
1755 },
1756
1757 SetupHooks {
1769 #[arg(long, value_enum)]
1771 agent: Option<setup_hooks::HookAgentArg>,
1772
1773 #[arg(long)]
1775 dry_run: bool,
1776
1777 #[arg(long)]
1780 force: bool,
1781
1782 #[arg(long)]
1784 user: bool,
1785
1786 #[arg(long)]
1788 gitignore_claude: bool,
1789
1790 #[arg(long)]
1794 uninstall: bool,
1795 },
1796
1797 Viz {
1799 #[arg(long = "out", value_name = "PATH")]
1801 output: Option<PathBuf>,
1802
1803 #[arg(long)]
1805 no_open: bool,
1806
1807 #[arg(long = "viz-format", default_value = "html")]
1809 viz_format: viz::VizFormat,
1810 },
1811}
1812
1813#[derive(Subcommand)]
1814enum SecuritySubcommand {
1815 Survivors {
1817 #[arg(long, value_name = "PATH")]
1819 candidates: PathBuf,
1820 #[arg(long, value_name = "PATH")]
1822 verdicts: PathBuf,
1823 #[arg(long)]
1825 require_verdict_for_each_candidate: bool,
1826 },
1827 #[command(name = "blind-spots")]
1829 BlindSpots {
1830 #[arg(long, value_name = "PATH")]
1832 file: Vec<PathBuf>,
1833 },
1834}
1835
1836#[derive(clap::Subcommand)]
1837enum AuditCacheCli {
1838 Remove {
1844 #[arg(long)]
1846 dry_run: bool,
1847
1848 #[arg(long, alias = "force")]
1850 yes: bool,
1851 },
1852
1853 Prune {
1866 #[arg(long)]
1868 dry_run: bool,
1869
1870 #[arg(long, value_name = "N")]
1877 max_age_days: Option<u32>,
1878 },
1879}
1880
1881#[derive(clap::Subcommand)]
1882enum LicenseCli {
1883 Activate {
1888 #[arg(value_name = "JWT")]
1890 jwt: Option<String>,
1891
1892 #[arg(long, value_name = "PATH")]
1894 from_file: Option<PathBuf>,
1895
1896 #[arg(long, conflicts_with_all = ["jwt", "from_file"])]
1898 stdin: bool,
1899
1900 #[arg(long, requires = "email")]
1907 trial: bool,
1908
1909 #[arg(long, value_name = "ADDR")]
1911 email: Option<String>,
1912 },
1913 Status,
1915 Refresh,
1917 Deactivate,
1919}
1920
1921#[derive(Clone, Copy, clap::Subcommand)]
1922enum TelemetryCli {
1923 Status,
1925 Enable,
1927 Disable,
1929 Inspect {
1931 #[arg(long)]
1933 example: bool,
1934 },
1935}
1936
1937#[derive(clap::Subcommand)]
1938enum CiTemplateCli {
1939 Gitlab {
1941 #[arg(long, value_name = "DIR", num_args = 0..=1, default_missing_value = ".")]
1945 vendor: Option<PathBuf>,
1946
1947 #[arg(long)]
1949 force: bool,
1950 },
1951}
1952
1953#[derive(clap::Subcommand)]
1954enum CoverageCli {
1955 Setup {
1957 #[arg(short = 'y', long)]
1959 yes: bool,
1960
1961 #[arg(long)]
1963 non_interactive: bool,
1964
1965 #[arg(long)]
1967 json: bool,
1968 },
1969 Analyze {
1975 #[arg(long, value_name = "PATH", conflicts_with = "cloud")]
1977 runtime_coverage: Option<PathBuf>,
1978
1979 #[arg(long, visible_alias = "runtime-coverage-cloud")]
1981 cloud: bool,
1982
1983 #[arg(long, value_name = "KEY")]
1985 api_key: Option<String>,
1986
1987 #[arg(long, value_name = "URL")]
1989 api_endpoint: Option<String>,
1990
1991 #[arg(long, value_name = "OWNER/REPO")]
1997 repo: Option<String>,
1998
1999 #[arg(long, value_name = "ID")]
2001 project_id: Option<String>,
2002
2003 #[arg(long, value_name = "DAYS", default_value_t = 30)]
2005 coverage_period: u16,
2006
2007 #[arg(long, value_name = "ENV")]
2009 environment: Option<String>,
2010
2011 #[arg(long, value_name = "SHA")]
2013 commit_sha: Option<String>,
2014
2015 #[arg(long)]
2017 production: bool,
2018
2019 #[arg(long, default_value_t = 100)]
2021 min_invocations_hot: u64,
2022
2023 #[arg(long, value_name = "N")]
2025 min_observation_volume: Option<u32>,
2026
2027 #[arg(long, value_name = "RATIO")]
2029 low_traffic_threshold: Option<f64>,
2030
2031 #[arg(long)]
2033 top: Option<usize>,
2034
2035 #[arg(long)]
2037 blast_radius: bool,
2038
2039 #[arg(long)]
2041 importance: bool,
2042 },
2043 UploadInventory {
2054 #[arg(long, value_name = "KEY")]
2063 api_key: Option<String>,
2064
2065 #[arg(long, value_name = "URL")]
2070 api_endpoint: Option<String>,
2071
2072 #[arg(long, value_name = "PROJECT_ID")]
2077 project_id: Option<String>,
2078
2079 #[arg(long, value_name = "SHA")]
2084 git_sha: Option<String>,
2085
2086 #[arg(long)]
2092 allow_dirty: bool,
2093
2094 #[arg(long, value_name = "GLOB", num_args = 0..)]
2098 exclude_paths: Vec<String>,
2099
2100 #[arg(long, value_name = "PREFIX")]
2113 path_prefix: Option<String>,
2114
2115 #[arg(long)]
2117 dry_run: bool,
2118
2119 #[arg(long)]
2125 with_callers: bool,
2126
2127 #[arg(long)]
2131 ignore_upload_errors: bool,
2132 },
2133 UploadSourceMaps {
2146 #[arg(long, value_name = "PATH", default_value = "dist")]
2148 dir: PathBuf,
2149
2150 #[arg(long, value_name = "GLOB", default_value = "**/*.map")]
2152 include: String,
2153
2154 #[arg(long, value_name = "GLOB", default_value = "**/node_modules/**")]
2158 exclude: Vec<String>,
2159
2160 #[arg(long, value_name = "NAME")]
2164 repo: Option<String>,
2165
2166 #[arg(long, value_name = "SHA")]
2171 git_sha: Option<String>,
2172
2173 #[arg(long, value_name = "URL")]
2175 endpoint: Option<String>,
2176
2177 #[arg(long, value_name = "BOOL", default_value_t = true, action = clap::ArgAction::Set)]
2182 strip_path: bool,
2183
2184 #[arg(long)]
2186 dry_run: bool,
2187
2188 #[arg(long, value_name = "N", default_value_t = 4)]
2190 concurrency: usize,
2191
2192 #[arg(long)]
2194 fail_fast: bool,
2195 },
2196 UploadStaticFindings {
2203 #[arg(long, value_name = "KEY")]
2213 api_key: Option<String>,
2214
2215 #[arg(long, value_name = "URL")]
2220 api_endpoint: Option<String>,
2221
2222 #[arg(long, value_name = "PROJECT_ID")]
2227 project_id: Option<String>,
2228
2229 #[arg(long, value_name = "SHA")]
2234 git_sha: Option<String>,
2235
2236 #[arg(long)]
2242 allow_dirty: bool,
2243
2244 #[arg(long)]
2246 dry_run: bool,
2247
2248 #[arg(long)]
2252 ignore_upload_errors: bool,
2253 },
2254}
2255
2256#[derive(Subcommand)]
2257enum CiCli {
2258 PlanPrComment {
2260 #[arg(long)]
2262 body: PathBuf,
2263
2264 #[arg(long)]
2266 marker_id: String,
2267
2268 #[arg(long)]
2270 clean: bool,
2271
2272 #[arg(long)]
2274 existing_comment_id: Option<String>,
2275
2276 #[arg(long)]
2278 existing_body: Option<PathBuf>,
2279 },
2280
2281 PostPrComment {
2283 #[arg(long, value_enum)]
2285 provider: CiProviderArg,
2286
2287 #[arg(long)]
2289 pr: Option<String>,
2290
2291 #[arg(long)]
2293 mr: Option<String>,
2294
2295 #[arg(long)]
2297 body: PathBuf,
2298
2299 #[arg(long)]
2301 envelope: Option<PathBuf>,
2302
2303 #[arg(long)]
2305 marker_id: String,
2306
2307 #[arg(long)]
2309 clean: bool,
2310
2311 #[arg(long)]
2313 repo: Option<String>,
2314
2315 #[arg(long = "project-id")]
2317 project_id: Option<String>,
2318
2319 #[arg(long = "api-url")]
2321 api_url: Option<String>,
2322
2323 #[arg(long)]
2325 dry_run: bool,
2326 },
2327
2328 PostReview {
2330 #[arg(long, value_enum)]
2332 provider: CiProviderArg,
2333
2334 #[arg(long)]
2336 pr: Option<String>,
2337
2338 #[arg(long)]
2340 mr: Option<String>,
2341
2342 #[arg(long)]
2344 envelope: PathBuf,
2345
2346 #[arg(long)]
2348 repo: Option<String>,
2349
2350 #[arg(long = "project-id")]
2352 project_id: Option<String>,
2353
2354 #[arg(long = "api-url")]
2356 api_url: Option<String>,
2357
2358 #[arg(long)]
2360 dry_run: bool,
2361 },
2362
2363 PostCheckRun {
2365 #[arg(long, value_enum)]
2367 provider: CiProviderArg,
2368
2369 #[arg(long)]
2371 decision: PathBuf,
2372
2373 #[arg(long)]
2375 repo: String,
2376
2377 #[arg(long = "head-sha")]
2379 head_sha: String,
2380
2381 #[arg(long = "api-url")]
2383 api_url: Option<String>,
2384
2385 #[arg(long = "split-gates")]
2387 split_gates: bool,
2388
2389 #[arg(long)]
2391 dry_run: bool,
2392 },
2393
2394 ReconcileReview {
2396 #[arg(long, value_enum)]
2398 provider: CiProviderArg,
2399
2400 #[arg(long)]
2402 pr: Option<String>,
2403
2404 #[arg(long)]
2406 mr: Option<String>,
2407
2408 #[arg(long)]
2410 envelope: PathBuf,
2411
2412 #[arg(long)]
2414 repo: Option<String>,
2415
2416 #[arg(long = "project-id")]
2418 project_id: Option<String>,
2419
2420 #[arg(long = "api-url")]
2422 api_url: Option<String>,
2423
2424 #[arg(long)]
2426 dry_run: bool,
2427 },
2428}
2429
2430#[derive(Subcommand)]
2431enum RulePackCli {
2432 Init {
2434 name: Option<String>,
2436
2437 #[arg(long, default_value = "starter")]
2439 template: String,
2440
2441 #[arg(long, default_value = "rule-packs")]
2443 dir: String,
2444
2445 #[arg(long)]
2447 no_config: bool,
2448 },
2449
2450 List,
2452
2453 Test {
2455 pack: Option<PathBuf>,
2457 },
2458
2459 Schema,
2461}
2462
2463#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
2465pub enum BaselineModeArg {
2466 #[default]
2468 Count,
2469 Identity,
2472}
2473
2474impl From<BaselineModeArg> for fallow_engine::baseline::HealthBaselineMode {
2475 fn from(value: BaselineModeArg) -> Self {
2476 match value {
2477 BaselineModeArg::Count => Self::Count,
2478 BaselineModeArg::Identity => Self::Identity,
2479 }
2480 }
2481}
2482
2483#[derive(Clone, Copy, Debug, clap::ValueEnum)]
2484enum CiProviderArg {
2485 Github,
2486 Gitlab,
2487}
2488
2489#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2491enum TypeAwareRequireArg {
2492 BestEffort,
2494 Complete,
2496}
2497
2498impl From<TypeAwareRequireArg> for fallow_config::TypeAwareRequire {
2499 fn from(value: TypeAwareRequireArg) -> Self {
2500 match value {
2501 TypeAwareRequireArg::BestEffort => Self::BestEffort,
2502 TypeAwareRequireArg::Complete => Self::Complete,
2503 }
2504 }
2505}
2506
2507#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2509pub enum EffortFilter {
2510 Low,
2511 Medium,
2512 High,
2513}
2514
2515impl EffortFilter {
2516 const fn to_estimate(self) -> fallow_output::EffortEstimate {
2518 match self {
2519 Self::Low => fallow_output::EffortEstimate::Low,
2520 Self::Medium => fallow_output::EffortEstimate::Medium,
2521 Self::High => fallow_output::EffortEstimate::High,
2522 }
2523 }
2524}
2525
2526#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2528pub enum HealthSeverityCli {
2529 Moderate,
2530 High,
2531 Critical,
2532}
2533
2534impl HealthSeverityCli {
2535 const fn to_health_severity(self) -> fallow_output::FindingSeverity {
2537 match self {
2538 Self::Moderate => fallow_output::FindingSeverity::Moderate,
2539 Self::High => fallow_output::FindingSeverity::High,
2540 Self::Critical => fallow_output::FindingSeverity::Critical,
2541 }
2542 }
2543}
2544
2545#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2551pub enum EmailModeArg {
2552 Raw,
2554 Handle,
2556 Anonymized,
2558 #[value(hide = true)]
2560 Hash,
2561}
2562
2563impl EmailModeArg {
2564 const fn to_config(self) -> fallow_config::EmailMode {
2566 match self {
2567 Self::Raw => fallow_config::EmailMode::Raw,
2568 Self::Handle => fallow_config::EmailMode::Handle,
2569 Self::Anonymized => fallow_config::EmailMode::Anonymized,
2570 Self::Hash => fallow_config::EmailMode::Hash,
2571 }
2572 }
2573}
2574
2575#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2577pub enum AuditGateArg {
2578 NewOnly,
2580 All,
2582}
2583
2584impl From<AuditGateArg> for fallow_config::AuditGate {
2585 fn from(value: AuditGateArg) -> Self {
2586 match value {
2587 AuditGateArg::NewOnly => Self::NewOnly,
2588 AuditGateArg::All => Self::All,
2589 }
2590 }
2591}
2592
2593fn parse_min_occurrences(s: &str) -> Result<usize, String> {
2597 let value: usize = s
2598 .parse()
2599 .map_err(|_| format!("`{s}` is not a non-negative integer"))?;
2600 if value < 2 {
2601 return Err(format!(
2602 "must be at least 2 (got {value}); a single occurrence isn't a duplicate"
2603 ));
2604 }
2605 Ok(value)
2606}
2607
2608fn resolve_audit_baseline_path(
2614 root: &std::path::Path,
2615 cli: Option<&std::path::Path>,
2616 config: Option<&str>,
2617) -> Option<PathBuf> {
2618 let path = cli.map(std::path::Path::to_path_buf).or_else(|| {
2619 config.map(|p| {
2620 let path = PathBuf::from(p);
2621 if path_util::is_absolute_path_any_platform(&path) {
2622 path
2623 } else {
2624 root.join(path)
2625 }
2626 })
2627 })?;
2628 if path_util::is_absolute_path_any_platform(&path) {
2629 Some(path)
2630 } else {
2631 Some(root.join(path))
2632 }
2633}
2634
2635fn emit_known_failure(
2636 message: &str,
2637 exit_code: u8,
2638 output: fallow_config::OutputFormat,
2639 reason: telemetry::FailureReason,
2640) -> ExitCode {
2641 telemetry::note_failure_reason(reason);
2642 emit_error(message, exit_code, output)
2643}
2644
2645fn emit_known_failure_with_style(
2646 message: &str,
2647 exit_code: u8,
2648 output: fallow_config::OutputFormat,
2649 json_style: json_style::JsonStyle,
2650 reason: telemetry::FailureReason,
2651) -> ExitCode {
2652 telemetry::note_failure_reason(reason);
2653 error::emit_error_with_style(message, exit_code, output, json_style)
2654}
2655
2656fn unsupported_security_global(cli: &Cli) -> Option<&'static str> {
2657 if cli.baseline.is_some() {
2658 Some("--baseline")
2659 } else if cli.save_baseline.is_some() {
2660 Some("--save-baseline")
2661 } else if cli.production {
2662 Some("--production")
2663 } else if cli.no_production {
2664 Some("--no-production")
2665 } else if cli.group_by.is_some() {
2666 Some("--group-by")
2667 } else if cli.performance {
2668 Some("--performance")
2669 } else if cli.explain_skipped {
2670 Some("--explain-skipped")
2671 } else if cli.fail_on_regression {
2672 Some("--fail-on-regression")
2673 } else if cli.regression_baseline.is_some() {
2674 Some("--regression-baseline")
2675 } else if cli.save_regression_baseline.is_some() {
2676 Some("--save-regression-baseline")
2677 } else if cli.dupes_mode.is_some() {
2678 Some("--dupes-mode")
2679 } else if cli.dupes_threshold.is_some() {
2680 Some("--dupes-threshold")
2681 } else if cli.dupes_min_tokens.is_some() {
2682 Some("--dupes-min-tokens")
2683 } else if cli.dupes_min_lines.is_some() {
2684 Some("--dupes-min-lines")
2685 } else if cli.dupes_min_occurrences.is_some() {
2686 Some("--dupes-min-occurrences")
2687 } else if cli.dupes_skip_local {
2688 Some("--dupes-skip-local")
2689 } else if cli.dupes_cross_language {
2690 Some("--dupes-cross-language")
2691 } else if cli.dupes_ignore_imports {
2692 Some("--dupes-ignore-imports")
2693 } else if cli.dupes_no_ignore_imports {
2694 Some("--dupes-no-ignore-imports")
2695 } else if cli.include_entry_exports {
2696 Some("--include-entry-exports")
2697 } else {
2698 None
2699 }
2700}
2701
2702struct DispatchContext<'a> {
2703 cli: &'a Cli,
2704 root: &'a std::path::Path,
2705 output: fallow_config::OutputFormat,
2706 quiet: bool,
2707 fail_on_issues: bool,
2708 json_style: json_style::JsonStyle,
2709 threads: usize,
2710 tolerance: regression::Tolerance,
2711 save_regression_file: Option<&'a std::path::PathBuf>,
2712 save_to_config: bool,
2713}
2714
2715impl DispatchContext<'_> {
2716 fn production_modes(
2717 &self,
2718 dead_code: bool,
2719 health: bool,
2720 dupes: bool,
2721 ) -> Result<ProductionModes, ExitCode> {
2722 resolve_production_modes(self.cli, self.root, self.output, dead_code, health, dupes)
2723 }
2724
2725 fn production_for(
2726 &self,
2727 analysis: fallow_config::ProductionAnalysis,
2728 ) -> Result<bool, ExitCode> {
2729 self.production_modes(false, false, false)
2730 .map(|modes| modes.for_analysis(analysis))
2731 }
2732
2733 fn regression_opts(&self, scoped: bool) -> regression::RegressionOpts<'_> {
2734 regression::RegressionOpts {
2735 fail_on_regression: self.cli.fail_on_regression,
2736 tolerance: self.tolerance,
2737 regression_baseline_file: self.cli.regression_baseline.as_deref(),
2738 save_target: if let Some(path) = self.save_regression_file {
2739 regression::SaveRegressionTarget::File(path)
2740 } else if self.save_to_config {
2741 regression::SaveRegressionTarget::Config
2742 } else {
2743 regression::SaveRegressionTarget::None
2744 },
2745 scoped,
2746 quiet: self.quiet,
2747 output: self.output,
2748 }
2749 }
2750}
2751
2752#[cfg(unix)]
2767fn signal_test_helper() -> ExitCode {
2768 use std::io::Write as _;
2769 use std::process::Command;
2770
2771 if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER_GRACEFUL").is_some() {
2772 signal::set_graceful_mode();
2773 }
2774
2775 let mut command = Command::new("sleep");
2776 command.arg("30");
2777 let child = match signal::ScopedChild::spawn(&mut command) {
2778 Ok(c) => c,
2779 Err(err) => {
2780 let _ = writeln!(std::io::stderr(), "spawn sleep failed: {err}");
2781 return ExitCode::from(2);
2782 }
2783 };
2784 let pid = child.id();
2785 let stdout = std::io::stdout();
2786 let mut lock = stdout.lock();
2787 let _ = writeln!(lock, "{pid}");
2788 let _ = lock.flush();
2789 drop(lock);
2790 let _ = child.wait_with_output();
2791 if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER_GRACEFUL").is_some() {
2792 return ExitCode::SUCCESS;
2793 }
2794 std::thread::sleep(std::time::Duration::from_secs(5));
2795 ExitCode::SUCCESS
2796}
2797
2798#[cfg(not(unix))]
2799fn signal_test_helper() -> ExitCode {
2800 ExitCode::from(2)
2801}
2802
2803fn install_spawn_hooks() {
2804 fallow_engine::churn::set_spawn_hook(signal::scoped_child::output);
2805 fallow_engine::changed_files::set_spawn_hook(signal::scoped_child::output);
2806}
2807
2808fn install_signal_handlers() {
2809 if let Err(err) = signal::install_handlers() {
2810 use std::io::Write as _;
2811 let stderr = std::io::stderr();
2812 let mut lock = stderr.lock();
2813 let _ = writeln!(lock, "fallow: failed to install signal handlers: {err}");
2814 }
2815}
2816
2817fn redirect_report_to_file(
2822 path: &std::path::Path,
2823 output: fallow_config::OutputFormat,
2824) -> Result<(), ExitCode> {
2825 if let Some(parent) = path.parent()
2826 && !parent.as_os_str().is_empty()
2827 && let Err(e) = std::fs::create_dir_all(parent)
2828 {
2829 return Err(emit_error(
2830 &format!(
2831 "failed to create {} for --output-file: {e}",
2832 parent.display()
2833 ),
2834 2,
2835 output,
2836 ));
2837 }
2838 match std::fs::File::create(path) {
2839 Ok(file) => {
2840 report::sink::set_file_sink(file);
2841 colored::control::set_override(false);
2842 Ok(())
2843 }
2844 Err(e) => Err(emit_error(
2845 &format!("failed to open {} for --output-file: {e}", path.display()),
2846 2,
2847 output,
2848 )),
2849 }
2850}
2851
2852fn finalize_report_file(
2855 path: &std::path::Path,
2856 quiet: bool,
2857 output: fallow_config::OutputFormat,
2858) -> Result<(), ExitCode> {
2859 if let Err(e) = report::sink::flush() {
2860 return Err(emit_error(
2861 &format!("failed to write {}: {e}", path.display()),
2862 2,
2863 output,
2864 ));
2865 }
2866 if !quiet && report::sink::wrote() {
2870 eprintln!("Report written to {}", path.display());
2871 }
2872 Ok(())
2873}
2874
2875pub fn run() -> ExitCode {
2880 install_signal_handlers();
2881 install_spawn_hooks();
2882
2883 if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER").is_some() {
2884 return signal_test_helper();
2885 }
2886
2887 let (mut cli, fmt) = match parse_cli_args() {
2888 Ok(parsed) => parsed,
2889 Err(code) => return code,
2890 };
2891 if cli.pretty && !fmt.payload_is_json {
2892 eprintln!(
2893 "Error: --pretty requires JSON output. Use --format json --pretty, or remove --pretty."
2894 );
2895 return ExitCode::from(2);
2896 }
2897
2898 if let Some(code) = run_schema_command_if_requested(&cli, fmt.json_style) {
2899 return code;
2900 }
2901
2902 if let Some(code) = run_telemetry_command_if_requested(&mut cli, fmt.output, fmt.json_style) {
2903 return code;
2904 }
2905 if is_impact_statusline(&cli) {
2906 let (root, _) = match validate_inputs(&cli, fmt.output, fmt.json_style) {
2907 Ok(validated) => validated,
2908 Err(code) => return code,
2909 };
2910 return cli_impact::render_impact_statusline(&root);
2911 }
2912 let telemetry_run = start_telemetry_run(&cli, &fmt);
2913
2914 let (root, threads) = match validate_inputs(&cli, fmt.output, fmt.json_style) {
2915 Ok(v) => v,
2916 Err(code) => {
2917 return record_run_epilogue(telemetry_run, code, None, cli.parent_run.as_deref());
2918 }
2919 };
2920
2921 let FormatConfig {
2922 output,
2923 payload_is_json: _,
2924 quiet,
2925 fail_on_issues,
2926 json_style,
2927 } = fmt;
2928
2929 let tolerance =
2930 match run_pre_dispatch_checks(&cli, &root, output, json_style, quiet, telemetry_run) {
2931 Ok(tolerance) => tolerance,
2932 Err(code) => return code,
2933 };
2934
2935 let (save_regression_file, save_to_config) = regression_save_targets(&cli);
2936
2937 let command = cli.command.take();
2938 let dispatch = DispatchContext {
2939 cli: &cli,
2940 root: &root,
2941 output,
2942 quiet,
2943 fail_on_issues,
2944 json_style,
2945 threads,
2946 tolerance,
2947 save_regression_file: save_regression_file.as_ref(),
2948 save_to_config,
2949 };
2950 let exit_code = match dispatch_and_finalize(&dispatch, command) {
2951 Ok(code) => code,
2952 Err(code) => return code,
2953 };
2954 record_run_epilogue(telemetry_run, exit_code, None, cli.parent_run.as_deref())
2955}
2956
2957#[doc(hidden)]
2961pub fn benchmark_fix_dry_run(root: &Path, threads: usize) -> (ExitCode, usize) {
2962 let config_path = None;
2963 fix::run_fix_with_count(&fix::FixOptions {
2964 root,
2965 config_path: &config_path,
2966 output: fallow_config::OutputFormat::Json,
2967 json_style: json_style::JsonStyle::Compact,
2968 no_cache: true,
2969 threads,
2970 quiet: true,
2971 emit_output: false,
2972 allow_remote_extends: false,
2973 dry_run: true,
2974 yes: false,
2975 production: false,
2976 no_create_config: true,
2977 type_aware: None,
2978 type_aware_projects: &[],
2979 type_aware_require: None,
2980 })
2981}
2982
2983#[doc(hidden)]
2986pub use audit::AuditReviewBenchmarkCorpus;
2987
2988#[doc(hidden)]
2991pub fn create_audit_review_benchmark_corpus(
2992 root: &Path,
2993 changed_files: &[PathBuf],
2994 threads: usize,
2995) -> Result<AuditReviewBenchmarkCorpus, ExitCode> {
2996 audit::create_audit_review_benchmark_corpus(root, changed_files, threads)
2997}
2998
2999#[doc(hidden)]
3002pub fn benchmark_audit_review_brief_many_changed_files_json(
3003 corpus: &mut AuditReviewBenchmarkCorpus,
3004) -> (ExitCode, usize, usize, usize, usize, usize) {
3005 match audit::benchmark_audit_review_brief_many_changed_files_json(corpus) {
3006 Ok(result) => (
3007 ExitCode::SUCCESS,
3008 result.introduced_count,
3009 result.inherited_count,
3010 result.public_api_added_count,
3011 result.decision_count,
3012 result.rendered_bytes,
3013 ),
3014 Err(code) => (code, 0, 0, 0, 0, 0),
3015 }
3016}
3017
3018#[doc(hidden)]
3019pub use inspect::InspectBenchmarkCorpus;
3020
3021#[doc(hidden)]
3024pub fn create_inspect_benchmark_corpus(root: &Path, threads: usize) -> InspectBenchmarkCorpus {
3025 inspect::create_inspect_benchmark_corpus(root, threads)
3026}
3027
3028#[doc(hidden)]
3031pub fn benchmark_inspect_file_evidence_bundle_json(
3032 root: &Path,
3033 threads: usize,
3034 corpus: &InspectBenchmarkCorpus,
3035) -> (ExitCode, usize, usize) {
3036 match inspect::benchmark_inspect_file_evidence_bundle_json(root, threads, corpus) {
3037 Ok((child_call_count, rendered_bytes)) => {
3038 (ExitCode::SUCCESS, child_call_count, rendered_bytes)
3039 }
3040 Err(_) => (ExitCode::from(2), 0, 0),
3041 }
3042}
3043
3044#[doc(hidden)]
3047pub fn benchmark_dead_code_json(root: &Path, threads: usize) -> (ExitCode, usize, usize) {
3048 match check::benchmark_dead_code_json(root, threads) {
3049 Ok((issue_count, rendered_bytes)) => (ExitCode::SUCCESS, issue_count, rendered_bytes),
3050 Err(code) => (code, 0, 0),
3051 }
3052}
3053
3054#[doc(hidden)]
3057pub fn benchmark_security_json(root: &Path, threads: usize) -> (ExitCode, usize, usize) {
3058 match security::benchmark_security_json(root, threads) {
3059 Ok((finding_count, rendered_bytes)) => (ExitCode::SUCCESS, finding_count, rendered_bytes),
3060 Err(code) => (code, 0, 0),
3061 }
3062}
3063
3064#[doc(hidden)]
3065pub use security::{SecurityBlindSpotsBenchmarkResult, SecuritySurvivorsBenchmarkCorpus};
3066
3067#[doc(hidden)]
3070pub fn create_security_survivors_benchmark_corpus(
3071 root: &Path,
3072 threads: usize,
3073) -> Result<SecuritySurvivorsBenchmarkCorpus, ExitCode> {
3074 security::create_security_survivors_benchmark_corpus(root, threads)
3075}
3076
3077#[doc(hidden)]
3080pub fn benchmark_security_survivors_json(
3081 corpus: &SecuritySurvivorsBenchmarkCorpus,
3082) -> (ExitCode, usize, usize, usize, usize, usize) {
3083 match security::benchmark_security_survivors_json(corpus) {
3084 Ok((survivors, dismissed, needs_human_review, unverdicted, rendered_bytes)) => (
3085 ExitCode::SUCCESS,
3086 survivors,
3087 dismissed,
3088 needs_human_review,
3089 unverdicted,
3090 rendered_bytes,
3091 ),
3092 Err(_) => (ExitCode::from(2), 0, 0, 0, 0, 0),
3093 }
3094}
3095
3096#[doc(hidden)]
3099pub fn benchmark_security_blind_spots_json(
3100 root: &Path,
3101 diagnostics: &[fallow_types::results::SecurityUnresolvedCalleeDiagnostic],
3102) -> SecurityBlindSpotsBenchmarkResult {
3103 security::benchmark_security_blind_spots_json(root, diagnostics)
3104}
3105
3106#[doc(hidden)]
3109pub fn benchmark_list_json(root: &Path, threads: usize) -> (ExitCode, usize, usize, usize, usize) {
3110 match list::benchmark_list_json(root, threads) {
3111 Ok((file_count, entry_point_count, workspace_count, rendered_bytes)) => (
3112 ExitCode::SUCCESS,
3113 file_count,
3114 entry_point_count,
3115 workspace_count,
3116 rendered_bytes,
3117 ),
3118 Err(code) => (code, 0, 0, 0, 0),
3119 }
3120}
3121
3122#[doc(hidden)]
3125pub fn benchmark_list_boundaries_json(
3126 root: &Path,
3127 threads: usize,
3128) -> (ExitCode, usize, usize, usize, usize) {
3129 match list::benchmark_list_boundaries_json(root, threads) {
3130 Ok((zone_count, rule_count, matched_file_count, rendered_bytes)) => (
3131 ExitCode::SUCCESS,
3132 zone_count,
3133 rule_count,
3134 matched_file_count,
3135 rendered_bytes,
3136 ),
3137 Err(code) => (code, 0, 0, 0, 0),
3138 }
3139}
3140
3141#[doc(hidden)]
3144pub use watch::WatchFilterBenchmarkGlobalGitignore;
3145
3146#[doc(hidden)]
3149pub fn create_watch_filter_benchmark_global_gitignore() -> WatchFilterBenchmarkGlobalGitignore {
3150 watch::create_benchmark_global_gitignore()
3151}
3152
3153#[doc(hidden)]
3156pub fn benchmark_watch_filter_initialization(
3157 config: &fallow_config::ResolvedConfig,
3158 global_gitignore: &WatchFilterBenchmarkGlobalGitignore,
3159) -> (usize, usize) {
3160 watch::benchmark_filter_initialization(config, global_gitignore)
3161}
3162
3163#[doc(hidden)]
3166pub fn benchmark_viz_html(root: &Path, threads: usize) -> (ExitCode, usize, usize, usize) {
3167 match viz::benchmark_viz_html(root, threads) {
3168 Ok((file_count, edge_count, rendered_bytes)) => {
3169 (ExitCode::SUCCESS, file_count, edge_count, rendered_bytes)
3170 }
3171 Err(code) => (code, 0, 0, 0),
3172 }
3173}
3174
3175#[doc(hidden)]
3178pub fn benchmark_rule_pack_test_json(root: &Path, threads: usize) -> (ExitCode, usize, usize) {
3179 match rule_pack::benchmark_rule_pack_test_json(root, threads) {
3180 Ok((finding_count, rendered_bytes)) => (ExitCode::SUCCESS, finding_count, rendered_bytes),
3181 Err(code) => (code, 0, 0),
3182 }
3183}
3184
3185#[doc(hidden)]
3188pub fn benchmark_recommend_json(root: &Path) -> (ExitCode, usize, usize, bool, usize) {
3189 match onboarding::benchmark_recommend_json(root) {
3190 Ok((decision_count, framework_count, heterogeneous, rendered_bytes)) => (
3191 ExitCode::SUCCESS,
3192 decision_count,
3193 framework_count,
3194 heterogeneous,
3195 rendered_bytes,
3196 ),
3197 Err(_) => (ExitCode::from(2), 0, 0, false, 0),
3198 }
3199}
3200
3201#[doc(hidden)]
3204pub fn benchmark_runtime_coverage_analyze_json(
3205 root: &Path,
3206 runtime_coverage_path: &Path,
3207 response_bytes: &[u8],
3208 threads: usize,
3209) -> (ExitCode, usize, usize, usize, String) {
3210 match coverage::benchmark_local_json(root, runtime_coverage_path, response_bytes, threads) {
3211 Ok((finding_count, hot_path_count, request_bytes, rendered)) => (
3212 ExitCode::SUCCESS,
3213 finding_count,
3214 hot_path_count,
3215 request_bytes,
3216 rendered,
3217 ),
3218 Err(code) => (code, 0, 0, 0, String::new()),
3219 }
3220}
3221
3222fn is_impact_statusline(cli: &Cli) -> bool {
3225 matches!(
3226 cli.command.as_ref(),
3227 Some(Command::Impact {
3228 subcommand: Some(ImpactCli::Statusline),
3229 all: false,
3230 ..
3231 })
3232 )
3233}
3234
3235fn dispatch_and_finalize(
3239 dispatch: &DispatchContext<'_>,
3240 command: Option<Command>,
3241) -> Result<ExitCode, ExitCode> {
3242 let cli = dispatch.cli;
3243 let output = dispatch.output;
3244 let quiet = dispatch.quiet;
3245
3246 if let Some(path) = cli.output_file.as_deref()
3249 && let Err(code) = redirect_report_to_file(path, output)
3250 {
3251 return Err(code);
3252 }
3253
3254 let exit_code = if command.is_some() && cli_has_bare_coverage_input(cli) {
3255 emit_error(bare_coverage_subcommand_error_message(), 2, output)
3256 } else {
3257 match command {
3258 None => dispatch_bare_command(dispatch),
3259 Some(cmd) => dispatch_subcommand(cmd, dispatch),
3260 }
3261 };
3262
3263 if let Some(path) = cli.output_file.as_deref()
3264 && let Err(code) = finalize_report_file(path, quiet, output)
3265 {
3266 return Err(code);
3267 }
3268 Ok(exit_code)
3269}
3270
3271fn run_telemetry_command_if_requested(
3272 cli: &mut Cli,
3273 output: fallow_config::OutputFormat,
3274 json_style: json_style::JsonStyle,
3275) -> Option<ExitCode> {
3276 if matches!(cli.command, Some(Command::Telemetry { .. }))
3277 && let Some(Command::Telemetry { subcommand }) = cli.command.take()
3278 {
3279 return Some(telemetry::run(
3280 map_telemetry_subcommand(subcommand),
3281 output,
3282 json_style,
3283 ));
3284 }
3285 None
3286}
3287
3288fn run_schema_command_if_requested(
3289 cli: &Cli,
3290 json_style: json_style::JsonStyle,
3291) -> Option<ExitCode> {
3292 match cli.command {
3293 Some(Command::Schema) => Some(schema::run_schema(json_style)),
3294 Some(Command::ConfigSchema) => Some(init::run_config_schema(json_style)),
3295 Some(Command::PluginSchema) => Some(init::run_plugin_schema(json_style)),
3296 Some(Command::RulePackSchema) => Some(init::run_rule_pack_schema(json_style)),
3297 _ => None,
3298 }
3299}
3300
3301fn regression_save_targets(cli: &Cli) -> (Option<std::path::PathBuf>, bool) {
3302 let save_file = cli.save_regression_baseline.as_ref().and_then(|opt| {
3303 opt.as_ref()
3304 .filter(|path| !path.is_empty())
3305 .map(std::path::PathBuf::from)
3306 });
3307 let save_to_config = cli.save_regression_baseline.is_some() && save_file.is_none();
3308 (save_file, save_to_config)
3309}
3310
3311fn dispatch_bare_command(dispatch: &DispatchContext<'_>) -> ExitCode {
3312 let cli = dispatch.cli;
3313 let (run_check, run_dupes, run_health) = combined::resolve_analyses(&cli.only, &cli.skip);
3314 let production = match dispatch.production_modes(
3315 cli.production_dead_code,
3316 cli.production_health,
3317 cli.production_dupes,
3318 ) {
3319 Ok(production) => production,
3320 Err(code) => return code,
3321 };
3322 let coverage_inputs = if run_health {
3327 match resolve_health_coverage_inputs(
3328 dispatch,
3329 cli.coverage.as_deref(),
3330 cli.coverage_root.as_deref(),
3331 ) {
3332 Ok(inputs) => inputs,
3333 Err(code) => return code,
3334 }
3335 } else {
3336 ResolvedHealthCoverageInputs::default()
3337 };
3338 run_bare_combined(
3339 dispatch,
3340 production,
3341 &coverage_inputs,
3342 BareAnalyses {
3343 run_check,
3344 run_dupes,
3345 run_health,
3346 },
3347 )
3348}
3349
3350#[derive(Clone, Copy)]
3352struct BareAnalyses {
3353 run_check: bool,
3354 run_dupes: bool,
3355 run_health: bool,
3356}
3357
3358fn run_bare_combined(
3361 dispatch: &DispatchContext<'_>,
3362 production: ProductionModes,
3363 coverage_inputs: &ResolvedHealthCoverageInputs,
3364 analyses: BareAnalyses,
3365) -> ExitCode {
3366 let cli = dispatch.cli;
3367 let (output, quiet, fail_on_issues) =
3368 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
3369 combined::run_combined(&combined::CombinedOptions {
3370 root: dispatch.root,
3371 config_path: &cli.config,
3372 output,
3373 json_style: dispatch.json_style,
3374 no_cache: cli.no_cache,
3375 threads: dispatch.threads,
3376 quiet,
3377 allow_remote_extends: cli.allow_remote_extends,
3378 fail_on_issues,
3379 sarif_file: cli.sarif_file.as_deref(),
3380 changed_since: cli.changed_since.as_deref(),
3381 churn_file: cli.churn_file.as_deref(),
3382 baseline: cli.baseline.as_deref(),
3383 save_baseline: cli.save_baseline.as_deref(),
3384 production: cli.production,
3385 production_dead_code: Some(production.dead_code),
3386 production_health: Some(production.health),
3387 production_dupes: Some(production.dupes),
3388 workspace: cli.workspace.as_deref(),
3389 changed_workspaces: cli.changed_workspaces.as_deref(),
3390 group_by: cli.group_by,
3391 type_aware: cli.type_aware_override(),
3392 type_aware_projects: &cli.type_aware_project,
3393 type_aware_require: cli.type_aware_require.map(Into::into),
3394 explain: cli.explain,
3395 explain_skipped: cli.explain_skipped,
3396 performance: cli.performance,
3397 summary: cli.summary,
3398 run_check: analyses.run_check,
3399 run_dupes: analyses.run_dupes,
3400 run_health: analyses.run_health,
3401 dupes_mode: cli.dupes_mode,
3402 dupes_near: cli.dupes_near,
3403 dupes_threshold: cli.dupes_threshold,
3404 dupes_min_tokens: cli.dupes_min_tokens,
3405 dupes_min_lines: cli.dupes_min_lines,
3406 dupes_min_occurrences: cli.dupes_min_occurrences,
3407 dupes_skip_local: cli.dupes_skip_local,
3408 dupes_cross_language: cli.dupes_cross_language,
3409 dupes_ignore_imports: resolve_ignore_imports(
3410 cli.dupes_ignore_imports,
3411 cli.dupes_no_ignore_imports,
3412 ),
3413 score: cli.score || cli.trend,
3414 trend: cli.trend,
3415 save_snapshot: cli.save_snapshot.as_ref(),
3416 coverage: coverage_inputs.coverage.as_deref(),
3417 coverage_root: coverage_inputs.coverage_root.as_deref(),
3418 include_entry_exports: cli.include_entry_exports,
3419 regression_opts: dispatch.regression_opts(
3420 cli.changed_since.is_some()
3421 || cli.workspace.is_some()
3422 || cli.changed_workspaces.is_some(),
3423 ),
3424 })
3425}
3426
3427#[allow(
3428 clippy::too_many_lines,
3429 reason = "the command router is intentionally an exhaustive top-level dispatch table"
3430)]
3431fn dispatch_subcommand(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3432 let cli = dispatch.cli;
3433 let root = dispatch.root;
3434 let output = dispatch.output;
3435 let quiet = dispatch.quiet;
3436 match command {
3437 check @ Command::Check { .. } => dispatch_check_command(check, dispatch),
3438 Command::Watch { no_clear } => dispatch_watch(dispatch, no_clear),
3439 Command::TypeAware { subcommand } => dispatch_type_aware_command(dispatch, subcommand),
3440 Command::SimilarCode {
3441 subcommand,
3442 threshold,
3443 min_lines,
3444 top,
3445 file,
3446 } => similar_code_cli::run(similar_code_cli::SimilarCodeCliInput {
3447 root,
3448 config_path: cli.config.as_deref(),
3449 allow_remote_extends: cli.allow_remote_extends,
3450 no_cache: cli.no_cache,
3451 threads: dispatch.threads,
3452 changed_since: cli.changed_since.as_deref(),
3453 diff_file: cli.diff_file.as_deref(),
3454 workspace: cli.workspace.as_deref(),
3455 changed_workspaces: cli.changed_workspaces.as_deref(),
3456 explain: cli.explain,
3457 quiet,
3458 output,
3459 json_style: dispatch.json_style,
3460 threshold,
3461 min_lines,
3462 top,
3463 files: file,
3464 subcommand,
3465 }),
3466 Command::Inspect {
3467 file,
3468 symbol,
3469 symbol_chain,
3470 churn,
3471 } => dispatch_inspect_command(dispatch, file, symbol, symbol_chain, churn),
3472 Command::Trace {
3473 symbol,
3474 callers,
3475 callees,
3476 depth,
3477 } => dispatch_trace_command(dispatch, symbol, callers, callees, depth),
3478 fix @ Command::Fix { .. } => dispatch_fix_command(&fix, dispatch),
3479 init @ Command::Init { .. } => dispatch_init_command(init, root, quiet),
3480 Command::Hooks { subcommand } => {
3481 run_hooks_command(root, subcommand, output, dispatch.json_style)
3482 }
3483 Command::Agent { subcommand } => dispatch_agent_command(dispatch, subcommand),
3484 Command::Ci { subcommand } => {
3485 ci::run(map_ci_subcommand(subcommand), output, dispatch.json_style)
3486 }
3487 Command::ConfigSchema => init::run_config_schema(dispatch.json_style),
3488 Command::PluginSchema => init::run_plugin_schema(dispatch.json_style),
3489 Command::PluginCheck => plugin_check::run_plugin_check(root, output, dispatch.json_style),
3490 Command::RulePackSchema => init::run_rule_pack_schema(dispatch.json_style),
3491 Command::RulePack { subcommand } => dispatch_rule_pack_command(dispatch, subcommand),
3492 Command::Guard { files } => dispatch_guard_command(dispatch, &files),
3493 Command::CiTemplate { subcommand } => dispatch_ci_template_command(subcommand),
3494 Command::Config { path } => config::run_config_with_options(config::RunConfigInput {
3495 root,
3496 explicit_config: cli.config.as_deref(),
3497 path_only: path,
3498 output,
3499 quiet,
3500 json_style: dispatch.json_style,
3501 load_options: fallow_config::ConfigLoadOptions {
3502 allow_remote_extends: cli.allow_remote_extends,
3503 },
3504 }),
3505 Command::Recommend => onboarding::run_recommend(root, output, dispatch.json_style),
3506 list @ (Command::Workspaces | Command::List { .. }) => {
3507 dispatch_list_command(&list, dispatch)
3508 }
3509 dupes @ Command::Dupes { .. } => dispatch_dupes_command(dupes, dispatch),
3510 health @ Command::Health { .. } => dispatch_health_command(health, dispatch),
3511 Command::Flags { top } => dispatch_flags_command(dispatch, top),
3512 Command::Suppressions { file } => dispatch_suppressions_command(dispatch, &file),
3513 Command::Explain { issue_type } => {
3514 explain::run_explain(&issue_type.join(" "), output, dispatch.json_style)
3515 }
3516 audit @ Command::Audit { .. } => dispatch_audit_command(audit, dispatch),
3517 Command::AuditCache { subcommand } => dispatch_audit_cache_command(dispatch, &subcommand),
3518 Command::DecisionSurface { max_decisions } => {
3519 dispatch_decision_surface(dispatch, max_decisions)
3520 }
3521 Command::Impact {
3522 subcommand,
3523 all,
3524 sort,
3525 limit,
3526 } => dispatch_impact(
3527 root,
3528 quiet,
3529 output,
3530 dispatch.json_style,
3531 subcommand,
3532 ImpactCrossRepoOpts { all, sort, limit },
3533 ),
3534 security @ Command::Security { .. } => dispatch_security_command(security, dispatch),
3535 Command::Viz {
3536 output: viz_output,
3537 no_open,
3538 viz_format,
3539 } => dispatch_viz(dispatch, viz_output.as_deref(), no_open, viz_format),
3540 Command::Report { from } => {
3541 cli_report::run_report(&from, output, root, cli.config.as_deref())
3542 }
3543 Command::Schema => unreachable!("handled above"),
3544 migrate @ Command::Migrate { .. } => dispatch_migrate_command(migrate, root),
3545 Command::License { subcommand } => {
3546 dispatch_license_command(subcommand, output, dispatch.json_style)
3547 }
3548 Command::Telemetry { .. } => unreachable!("handled before root validation"),
3549 Command::Coverage { subcommand } => dispatch_coverage_command(dispatch, &subcommand),
3550 setup_hooks @ Command::SetupHooks { .. } => {
3551 dispatch_setup_hooks_command(&setup_hooks, dispatch)
3552 }
3553 }
3554}
3555
3556fn dispatch_type_aware_command(
3557 dispatch: &DispatchContext<'_>,
3558 subcommand: TypeAwareCli,
3559) -> ExitCode {
3560 match subcommand {
3561 TypeAwareCli::Status => {
3562 let status = fallow_api::type_aware_status(dispatch.root);
3563 match dispatch.output {
3564 fallow_config::OutputFormat::Json => {
3565 let output = type_aware_status_output(dispatch.root, status);
3566 match fallow_output::serialize_type_aware_status_json_output(
3567 output,
3568 crate::output_runtime::current_root_envelope_mode(),
3569 ) {
3570 Ok(value) => match dispatch.json_style.serialize(&value) {
3571 Ok(json) => {
3572 crate::report::sink::outln!("{json}");
3573 ExitCode::SUCCESS
3574 }
3575 Err(error) => emit_error(
3576 &format!("failed to serialize type-aware status: {error}"),
3577 2,
3578 dispatch.output,
3579 ),
3580 },
3581 Err(error) => emit_error(
3582 &format!("failed to build type-aware status: {error}"),
3583 2,
3584 dispatch.output,
3585 ),
3586 }
3587 }
3588 fallow_config::OutputFormat::Human => {
3589 if status.available {
3590 crate::report::sink::outln!(
3591 "{}",
3592 report::human_status_line(
3593 report::HumanStatus::Ok,
3594 format_args!(
3595 "Type-aware companion: available ({}, protocol {}, TypeScript {})",
3596 status.package_version.as_deref().unwrap_or("unknown"),
3597 status.protocol_version,
3598 status.backend_version.as_deref().unwrap_or("unknown"),
3599 )
3600 )
3601 );
3602 } else {
3603 crate::report::sink::outln!(
3604 "{}",
3605 report::human_status_line(
3606 report::HumanStatus::Inactive,
3607 "Type-aware companion: unavailable"
3608 )
3609 );
3610 if let Some(remediation) = status.remediation {
3611 crate::report::sink::outln!(
3612 "{}",
3613 report::human_status_line(
3614 report::HumanStatus::Warning,
3615 format_args!("Action: {remediation}")
3616 )
3617 );
3618 }
3619 }
3620 ExitCode::SUCCESS
3621 }
3622 _ => emit_error(
3623 "type-aware status supports human and json output",
3624 2,
3625 dispatch.output,
3626 ),
3627 }
3628 }
3629 }
3630}
3631
3632fn type_aware_status_output(
3633 root: &Path,
3634 status: fallow_api::TypeAwareStatus,
3635) -> fallow_output::TypeAwareStatusOutput {
3636 let companion_path = status.companion_path.as_deref().map(|path| {
3637 if let Ok(relative) = path.strip_prefix(root)
3638 && !relative.as_os_str().is_empty()
3639 {
3640 relative.to_string_lossy().replace('\\', "/")
3641 } else {
3642 path.file_name()
3643 .unwrap_or(path.as_os_str())
3644 .to_string_lossy()
3645 .into_owned()
3646 }
3647 });
3648 let remediation = status.remediation.map(|message| {
3649 let without_root = message.replace(root.to_string_lossy().as_ref(), ".");
3650 status.companion_path.as_deref().map_or_else(
3651 || without_root.clone(),
3652 |path| {
3653 without_root.replace(
3654 path.to_string_lossy().as_ref(),
3655 companion_path.as_deref().unwrap_or("fallow-type-aware"),
3656 )
3657 },
3658 )
3659 });
3660 fallow_output::TypeAwareStatusOutput {
3661 schema_version: fallow_types::envelope::SchemaVersion(
3662 fallow_output::TYPE_AWARE_STATUS_SCHEMA_VERSION,
3663 ),
3664 version: fallow_types::envelope::ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
3665 available: status.available,
3666 discovery_source: status.discovery_source.map(str::to_string),
3667 companion_path,
3668 package_version: status.package_version,
3669 protocol_version: status.protocol_version,
3670 backend_family: status.backend_family,
3671 backend_version: status.backend_version,
3672 remediation,
3673 }
3674}
3675
3676fn dispatch_check_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3678 let filters = check_issue_filters(&command);
3679 let Command::Check {
3680 include_dupes,
3681 trace,
3682 trace_file,
3683 trace_dependency,
3684 impact_closure,
3685 symbol_impact,
3686 top,
3687 file,
3688 ..
3689 } = command
3690 else {
3691 unreachable!("check dispatcher only handles check commands");
3692 };
3693
3694 dispatch_check(
3695 dispatch,
3696 &CheckDispatchArgs {
3697 filters,
3698 trace_opts: TraceOptions {
3699 trace_export: trace,
3700 trace_file,
3701 trace_dependency,
3702 impact_closure,
3703 symbol_impact,
3704 performance: dispatch.cli.performance,
3705 },
3706 include_dupes,
3707 type_aware: dispatch.cli.type_aware_override(),
3708 type_aware_project: dispatch.cli.type_aware_project.clone(),
3709 type_aware_require: dispatch.cli.type_aware_require,
3710 top,
3711 file,
3712 },
3713 )
3714}
3715
3716fn check_issue_filters(command: &Command) -> IssueFilters {
3721 check_issue_filters_framework(command, &check_issue_filters_core(command))
3722}
3723
3724fn check_issue_filters_core(command: &Command) -> IssueFilters {
3727 let Command::Check {
3728 unused_files,
3729 unused_exports,
3730 unused_deps,
3731 unused_types,
3732 private_type_leaks,
3733 unused_enum_members,
3734 unused_class_members,
3735 unresolved_imports,
3736 unlisted_deps,
3737 duplicate_exports,
3738 circular_deps,
3739 re_export_cycles,
3740 boundary_violations,
3741 policy_violations,
3742 stale_suppressions,
3743 ..
3744 } = command
3745 else {
3746 unreachable!("check filter builder only handles check commands");
3747 };
3748
3749 let mut filters = IssueFilters::default();
3750 for (flag, active) in [
3751 ("--unused-files", *unused_files),
3752 ("--unused-exports", *unused_exports),
3753 ("--unused-deps", *unused_deps),
3754 ("--unused-types", *unused_types),
3755 ("--private-type-leaks", *private_type_leaks),
3756 ("--unused-enum-members", *unused_enum_members),
3757 ("--unused-class-members", *unused_class_members),
3758 ("--unresolved-imports", *unresolved_imports),
3759 ("--unlisted-deps", *unlisted_deps),
3760 ("--duplicate-exports", *duplicate_exports),
3761 ("--circular-deps", *circular_deps),
3762 ("--re-export-cycles", *re_export_cycles),
3763 ("--boundary-violations", *boundary_violations),
3764 ("--policy-violations", *policy_violations),
3765 ("--stale-suppressions", *stale_suppressions),
3766 ] {
3767 enable_check_filter(&mut filters, flag, active);
3768 }
3769 filters
3770}
3771
3772fn check_issue_filters_framework(command: &Command, base: &IssueFilters) -> IssueFilters {
3775 let Command::Check {
3776 unused_store_members,
3777 unprovided_injects,
3778 unrendered_components,
3779 unused_component_props,
3780 unused_component_emits,
3781 unused_component_inputs,
3782 unused_component_outputs,
3783 unused_svelte_events,
3784 unused_server_actions,
3785 unused_load_data_keys,
3786 unused_catalog_entries,
3787 empty_catalog_groups,
3788 unresolved_catalog_references,
3789 unused_dependency_overrides,
3790 misconfigured_dependency_overrides,
3791 ..
3792 } = command
3793 else {
3794 unreachable!("check filter builder only handles check commands");
3795 };
3796
3797 let mut filters = base.clone();
3798 for (flag, active) in [
3799 ("--unused-store-members", *unused_store_members),
3800 ("--unprovided-injects", *unprovided_injects),
3801 ("--unrendered-components", *unrendered_components),
3802 ("--unused-component-props", *unused_component_props),
3803 ("--unused-component-emits", *unused_component_emits),
3804 ("--unused-component-inputs", *unused_component_inputs),
3805 ("--unused-component-outputs", *unused_component_outputs),
3806 ("--unused-svelte-events", *unused_svelte_events),
3807 ("--unused-server-actions", *unused_server_actions),
3808 ("--unused-load-data-keys", *unused_load_data_keys),
3809 ("--unused-catalog-entries", *unused_catalog_entries),
3810 ("--empty-catalog-groups", *empty_catalog_groups),
3811 (
3812 "--unresolved-catalog-references",
3813 *unresolved_catalog_references,
3814 ),
3815 (
3816 "--unused-dependency-overrides",
3817 *unused_dependency_overrides,
3818 ),
3819 (
3820 "--misconfigured-dependency-overrides",
3821 *misconfigured_dependency_overrides,
3822 ),
3823 ] {
3824 enable_check_filter(&mut filters, flag, active);
3825 }
3826 filters
3827}
3828
3829fn enable_check_filter(filters: &mut IssueFilters, flag: &str, active: bool) {
3830 if active {
3831 assert!(
3832 filters.enable_cli_filter_flag(flag),
3833 "check command uses unregistered dead-code filter flag {flag}"
3834 );
3835 }
3836}
3837
3838fn dispatch_inspect_command(
3839 dispatch: &DispatchContext<'_>,
3840 file: Option<String>,
3841 symbol: Option<String>,
3842 symbol_chain: bool,
3843 churn: bool,
3844) -> ExitCode {
3845 let target = match (file, symbol) {
3846 (Some(file), None) => inspect::InspectTarget::File { file },
3847 (None, Some(symbol)) => match symbol.rsplit_once(':') {
3848 Some((file, export_name))
3849 if !file.trim().is_empty() && !export_name.trim().is_empty() =>
3850 {
3851 inspect::InspectTarget::Symbol {
3852 file: file.to_string(),
3853 export_name: export_name.to_string(),
3854 }
3855 }
3856 _ => {
3857 return emit_error(
3858 "--symbol must be formatted as FILE:EXPORT",
3859 2,
3860 dispatch.output,
3861 );
3862 }
3863 },
3864 _ => {
3865 return emit_error(
3866 "inspect requires exactly one of --file or --symbol",
3867 2,
3868 dispatch.output,
3869 );
3870 }
3871 };
3872
3873 let churn_config = if churn {
3874 match load_config_for_analysis(
3875 dispatch.root,
3876 &dispatch.cli.config,
3877 ConfigLoadOptions {
3878 output: dispatch.output,
3879 no_cache: dispatch.cli.no_cache,
3880 threads: dispatch.threads,
3881 production_override: None,
3882 quiet: dispatch.quiet,
3883 allow_remote_extends: dispatch.cli.allow_remote_extends,
3884 },
3885 fallow_config::ProductionAnalysis::Health,
3886 ) {
3887 Ok(config) => Some(config),
3888 Err(code) => return code,
3889 }
3890 } else {
3891 None
3892 };
3893
3894 inspect::run_inspect(&inspect::InspectOptions {
3895 root: dispatch.root,
3896 config_path: dispatch.cli.config.as_ref(),
3897 output: dispatch.output,
3898 json_style: dispatch.json_style,
3899 no_cache: dispatch.cli.no_cache,
3900 no_production: dispatch.cli.no_production,
3901 max_file_size: dispatch.cli.max_file_size,
3902 threads: dispatch.threads,
3903 quiet: dispatch.quiet,
3904 production: dispatch.cli.production,
3905 workspace: dispatch.cli.workspace.as_ref(),
3906 target,
3907 churn_cache_dir: churn_config
3908 .as_ref()
3909 .map(|config| config.cache_dir.as_path()),
3910 symbol_chain,
3911 type_aware: dispatch.cli.type_aware_override(),
3912 type_aware_projects: &dispatch.cli.type_aware_project,
3913 type_aware_require: dispatch.cli.type_aware_require.map(Into::into),
3914 })
3915}
3916
3917fn dispatch_trace_command(
3918 dispatch: &DispatchContext<'_>,
3919 symbol: String,
3920 callers: bool,
3921 callees: bool,
3922 depth: Option<u32>,
3923) -> ExitCode {
3924 trace_chain::run_trace(&trace_chain::TraceChainOptions {
3925 root: dispatch.root,
3926 config_path: &dispatch.cli.config,
3927 output: dispatch.output,
3928 json_style: dispatch.json_style,
3929 no_cache: dispatch.cli.no_cache,
3930 threads: dispatch.threads,
3931 quiet: dispatch.quiet,
3932 allow_remote_extends: dispatch.cli.allow_remote_extends,
3933 target: symbol,
3934 callers,
3935 callees,
3936 depth: depth.unwrap_or(fallow_types::trace_chain::DEFAULT_TRACE_DEPTH),
3937 })
3938}
3939
3940fn dispatch_security_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3941 let Command::Security {
3942 subcommand,
3943 runtime_coverage,
3944 min_invocations_hot,
3945 file,
3946 gate,
3947 surface,
3948 } = command
3949 else {
3950 unreachable!("security dispatcher only handles security commands");
3951 };
3952
3953 let gate = gate.map(security::SecurityGateArg::into_mode);
3954 let cli = dispatch.cli;
3955 let (output, _quiet, fail_on_issues) =
3956 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
3957 let derived_flags = SecurityDerivedFlagState {
3958 output,
3959 json_style: dispatch.json_style,
3960 ci: cli.ci,
3961 fail_on_issues,
3962 sarif_file: cli.sarif_file.as_deref(),
3963 summary: cli.summary,
3964 explain: cli.explain,
3965 runtime_coverage: runtime_coverage.as_deref(),
3966 min_invocations_hot,
3967 file: file.as_slice(),
3968 gate,
3969 surface,
3970 };
3971 if let Some(code) = try_run_security_survivors(subcommand.as_ref(), &derived_flags) {
3972 return code;
3973 }
3974
3975 let scoped_files = scoped_security_files(&file, subcommand.as_ref());
3976 run_security_blind_spots_or_default(
3977 dispatch,
3978 &SecurityRunInputs {
3979 scoped_files: &scoped_files,
3980 subcommand: &subcommand,
3981 runtime_coverage: runtime_coverage.as_deref(),
3982 min_invocations_hot,
3983 gate,
3984 surface,
3985 },
3986 &derived_flags,
3987 )
3988}
3989
3990struct SecurityRunInputs<'a> {
3993 scoped_files: &'a [PathBuf],
3994 subcommand: &'a Option<SecuritySubcommand>,
3995 runtime_coverage: Option<&'a Path>,
3996 min_invocations_hot: u64,
3997 gate: Option<security::SecurityGateMode>,
3998 surface: bool,
3999}
4000
4001fn run_security_blind_spots_or_default(
4003 dispatch: &DispatchContext<'_>,
4004 inputs: &SecurityRunInputs<'_>,
4005 derived_flags: &SecurityDerivedFlagState<'_>,
4006) -> ExitCode {
4007 let cli = dispatch.cli;
4008 let (output, quiet, fail_on_issues) =
4009 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
4010 let opts = security::SecurityOptions {
4011 root: dispatch.root,
4012 config_path: &cli.config,
4013 output,
4014 json_style: dispatch.json_style,
4015 no_cache: cli.no_cache,
4016 threads: dispatch.threads,
4017 quiet,
4018 allow_remote_extends: cli.allow_remote_extends,
4019 fail_on_issues,
4020 sarif_file: cli.sarif_file.as_deref(),
4021 summary: cli.summary,
4022 changed_since: cli.changed_since.as_deref(),
4023 use_shared_diff_index: true,
4024 workspace: cli.workspace.as_deref(),
4025 changed_workspaces: cli.changed_workspaces.as_deref(),
4026 file: inputs.scoped_files,
4027 surface: inputs.surface,
4028 gate: inputs.gate,
4029 runtime_coverage: inputs.runtime_coverage,
4030 min_invocations_hot: inputs.min_invocations_hot,
4031 explain: cli.explain,
4032 };
4033 if matches!(
4034 inputs.subcommand,
4035 Some(SecuritySubcommand::BlindSpots { .. })
4036 ) {
4037 if let Some(code) = validate_security_blind_spots_flags(derived_flags) {
4038 return code;
4039 }
4040 security::run_blind_spots(&opts)
4041 } else {
4042 security::run(&opts)
4043 }
4044}
4045
4046fn try_run_security_survivors(
4049 subcommand: Option<&SecuritySubcommand>,
4050 flags: &SecurityDerivedFlagState<'_>,
4051) -> Option<ExitCode> {
4052 let Some(SecuritySubcommand::Survivors {
4053 candidates,
4054 verdicts,
4055 require_verdict_for_each_candidate,
4056 }) = subcommand
4057 else {
4058 return None;
4059 };
4060 if let Some(code) = validate_security_survivors_flags(flags) {
4061 return Some(code);
4062 }
4063 Some(security::run_survivors(
4064 &security::SecuritySurvivorsOptions {
4065 output: flags.output,
4066 json_style: flags.json_style,
4067 candidates,
4068 verdicts,
4069 require_verdict_for_each_candidate: *require_verdict_for_each_candidate,
4070 },
4071 ))
4072}
4073
4074fn scoped_security_files(
4076 file: &[PathBuf],
4077 subcommand: Option<&SecuritySubcommand>,
4078) -> Vec<PathBuf> {
4079 let mut scoped_files = file.to_vec();
4080 if let Some(SecuritySubcommand::BlindSpots {
4081 file: blind_spot_files,
4082 }) = subcommand
4083 {
4084 scoped_files.extend(blind_spot_files.iter().cloned());
4085 }
4086 scoped_files
4087}
4088
4089struct SecurityDerivedFlagState<'a> {
4090 output: fallow_config::OutputFormat,
4091 json_style: json_style::JsonStyle,
4092 ci: bool,
4093 fail_on_issues: bool,
4094 sarif_file: Option<&'a Path>,
4095 summary: bool,
4096 explain: bool,
4097 runtime_coverage: Option<&'a Path>,
4098 min_invocations_hot: u64,
4099 file: &'a [PathBuf],
4100 gate: Option<security::SecurityGateMode>,
4101 surface: bool,
4102}
4103
4104fn validate_security_survivors_flags(flags: &SecurityDerivedFlagState<'_>) -> Option<ExitCode> {
4105 let flag = if flags.ci {
4106 Some("--ci")
4107 } else if flags.fail_on_issues {
4108 Some("--fail-on-issues")
4109 } else if flags.sarif_file.is_some() {
4110 Some("--sarif-file")
4111 } else if flags.summary {
4112 Some("--summary")
4113 } else if flags.explain {
4114 Some("--explain")
4115 } else if flags.runtime_coverage.is_some() {
4116 Some("--runtime-coverage")
4117 } else if flags.min_invocations_hot != DEFAULT_MIN_INVOCATIONS_HOT {
4118 Some("--min-invocations-hot")
4119 } else if !flags.file.is_empty() {
4120 Some("--file")
4121 } else if flags.gate.is_some() {
4122 Some("--gate")
4123 } else if flags.surface {
4124 Some("--surface")
4125 } else {
4126 None
4127 }?;
4128 Some(emit_error(
4129 &format!("{flag} is not valid with `fallow security survivors`."),
4130 2,
4131 flags.output,
4132 ))
4133}
4134
4135fn validate_security_blind_spots_flags(flags: &SecurityDerivedFlagState<'_>) -> Option<ExitCode> {
4136 let flag = if flags.ci {
4137 Some("--ci")
4138 } else if flags.fail_on_issues {
4139 Some("--fail-on-issues")
4140 } else if flags.sarif_file.is_some() {
4141 Some("--sarif-file")
4142 } else if flags.summary {
4143 Some("--summary")
4144 } else if flags.explain {
4145 Some("--explain")
4146 } else if flags.runtime_coverage.is_some() {
4147 Some("--runtime-coverage")
4148 } else if flags.min_invocations_hot != DEFAULT_MIN_INVOCATIONS_HOT {
4149 Some("--min-invocations-hot")
4150 } else if flags.gate.is_some() {
4151 Some("--gate")
4152 } else if flags.surface {
4153 Some("--surface")
4154 } else {
4155 None
4156 }?;
4157 Some(emit_error(
4158 &format!("{flag} is not valid with `fallow security blind-spots`."),
4159 2,
4160 flags.output,
4161 ))
4162}
4163
4164fn dispatch_dupes_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4165 let Command::Dupes {
4166 mode,
4167 near,
4168 min_tokens,
4169 min_lines,
4170 min_occurrences,
4171 threshold,
4172 skip_local,
4173 cross_language,
4174 ignore_imports,
4175 no_ignore_imports,
4176 top,
4177 trace,
4178 } = command
4179 else {
4180 unreachable!("dupes dispatcher only handles dupes commands");
4181 };
4182
4183 dispatch_dupes(
4184 dispatch,
4185 &DupesDispatchArgs {
4186 mode,
4187 near,
4188 min_tokens,
4189 min_lines,
4190 min_occurrences,
4191 threshold,
4192 skip_local,
4193 cross_language,
4194 ignore_imports,
4195 no_ignore_imports,
4196 top,
4197 trace,
4198 },
4199 )
4200}
4201
4202fn dispatch_agent_command(dispatch: &DispatchContext<'_>, subcommand: AgentCli) -> ExitCode {
4203 run_agent_command(
4204 dispatch.root,
4205 dispatch.cli.root.is_some(),
4206 subcommand,
4207 dispatch.output,
4208 dispatch.json_style,
4209 )
4210}
4211
4212fn dispatch_init_command(command: Command, root: &Path, quiet: bool) -> ExitCode {
4213 let Command::Init {
4214 toml,
4215 agents,
4216 hooks,
4217 branch,
4218 decline,
4219 } = command
4220 else {
4221 unreachable!("init dispatcher only handles init commands");
4222 };
4223
4224 init::run_init(&init::InitOptions {
4225 root,
4226 use_toml: toml,
4227 agents,
4228 hooks,
4229 branch: branch.as_deref(),
4230 decline,
4231 quiet,
4232 })
4233}
4234
4235fn dispatch_fix_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4236 let Command::Fix {
4237 dry_run,
4238 yes,
4239 no_create_config,
4240 } = command
4241 else {
4242 unreachable!("fix dispatcher only handles fix commands");
4243 };
4244
4245 dispatch_fix(
4246 dispatch,
4247 FixDispatchArgs {
4248 dry_run: *dry_run,
4249 yes: *yes,
4250 no_create_config: *no_create_config,
4251 },
4252 )
4253}
4254
4255fn dispatch_list_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4256 match command {
4257 Command::Workspaces => dispatch_list(dispatch, ListDispatchArgs::workspaces()),
4258 Command::List {
4259 entry_points,
4260 files,
4261 plugins,
4262 boundaries,
4263 workspaces,
4264 } => dispatch_list(
4265 dispatch,
4266 ListDispatchArgs {
4267 entry_points: *entry_points,
4268 files: *files,
4269 plugins: *plugins,
4270 boundaries: *boundaries,
4271 workspaces: *workspaces,
4272 },
4273 ),
4274 _ => unreachable!("list dispatcher only handles list commands"),
4275 }
4276}
4277
4278fn dispatch_migrate_command(command: Command, root: &Path) -> ExitCode {
4279 let Command::Migrate {
4280 toml,
4281 jsonc,
4282 dry_run,
4283 from,
4284 } = command
4285 else {
4286 unreachable!("migrate dispatcher only handles migrate commands");
4287 };
4288
4289 migrate::run_migrate(root, toml, jsonc, dry_run, from.as_deref())
4290}
4291
4292fn dispatch_license_command(
4293 subcommand: LicenseCli,
4294 output: fallow_config::OutputFormat,
4295 json_style: json_style::JsonStyle,
4296) -> ExitCode {
4297 license::run(&map_license_subcommand(subcommand), output, json_style)
4298}
4299
4300fn dispatch_ci_template_command(subcommand: CiTemplateCli) -> ExitCode {
4301 match subcommand {
4302 CiTemplateCli::Gitlab { vendor, force } => {
4303 ci_template::run_gitlab_template(&ci_template::GitlabTemplateOptions {
4304 vendor_dir: vendor,
4305 force,
4306 })
4307 }
4308 }
4309}
4310
4311fn dispatch_coverage_command(dispatch: &DispatchContext<'_>, subcommand: &CoverageCli) -> ExitCode {
4312 let cli = dispatch.cli;
4313 coverage::run(
4314 map_coverage_subcommand(subcommand, cli.explain),
4315 &coverage::RunContext {
4316 root: dispatch.root,
4317 config_path: &cli.config,
4318 output: dispatch.output,
4319 json_style: dispatch.json_style,
4320 quiet: dispatch.quiet,
4321 no_cache: cli.no_cache,
4322 threads: dispatch.threads,
4323 explain: cli.explain,
4324 allow_remote_extends: cli.allow_remote_extends,
4325 },
4326 )
4327}
4328
4329fn dispatch_health_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4330 let Command::Health {
4331 max_cyclomatic,
4332 max_cognitive,
4333 max_crap,
4334 top,
4335 sort,
4336 complexity,
4337 complexity_breakdown,
4338 file_scores,
4339 coverage_gaps,
4340 hotspots,
4341 ownership,
4342 ownership_emails,
4343 targets,
4344 type_coupling,
4345 css,
4346 effort,
4347 score,
4348 min_score,
4349 min_severity,
4350 report_only,
4351 since,
4352 min_commits,
4353 save_snapshot,
4354 trend,
4355 coverage,
4356 coverage_root,
4357 runtime_coverage,
4358 min_invocations_hot,
4359 min_observation_volume,
4360 low_traffic_threshold,
4361 } = command
4362 else {
4363 unreachable!("health dispatcher only handles health commands");
4364 };
4365
4366 let ownership = ownership || ownership_emails.is_some();
4367 let hotspots = hotspots || ownership;
4368 let args = HealthDispatchArgs {
4369 max_cyclomatic,
4370 max_cognitive,
4371 max_crap,
4372 top,
4373 sort,
4374 complexity,
4375 complexity_breakdown,
4376 file_scores,
4377 coverage_gaps,
4378 hotspots,
4379 ownership,
4380 ownership_emails: ownership_emails.map(EmailModeArg::to_config),
4381 targets,
4382 type_coupling,
4383 css,
4384 effort,
4385 score,
4386 min_score,
4387 min_severity: min_severity.map(HealthSeverityCli::to_health_severity),
4388 report_only,
4389 since: since.as_deref(),
4390 min_commits,
4391 save_snapshot: save_snapshot.as_ref(),
4392 trend,
4393 coverage: coverage.as_deref(),
4394 coverage_root: coverage_root.as_deref(),
4395 runtime_coverage: runtime_coverage.as_deref(),
4396 min_invocations_hot,
4397 min_observation_volume,
4398 low_traffic_threshold,
4399 };
4400 dispatch_health(dispatch, &args)
4401}
4402
4403fn dispatch_setup_hooks_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4404 let Command::SetupHooks {
4405 agent,
4406 dry_run,
4407 force,
4408 user,
4409 gitignore_claude,
4410 uninstall,
4411 } = command
4412 else {
4413 unreachable!("setup-hooks dispatcher only handles setup-hooks commands");
4414 };
4415
4416 eprintln!(
4417 "warning: `fallow setup-hooks` is deprecated and will be removed in the next major; use `fallow agent install` or `fallow hooks install --target agent`."
4418 );
4419 setup_hooks::run_setup_hooks(&setup_hooks::SetupHooksOptions {
4420 root: dispatch.root,
4421 agent: *agent,
4422 dry_run: *dry_run,
4423 force: *force,
4424 user: *user,
4425 gitignore_claude: *gitignore_claude,
4426 uninstall: *uninstall,
4427 })
4428}
4429
4430fn dispatch_audit_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4431 let Command::Audit {
4432 production_dead_code,
4433 production_health,
4434 production_dupes,
4435 dead_code_baseline,
4436 health_baseline,
4437 dupes_baseline,
4438 max_crap,
4439 coverage,
4440 coverage_root,
4441 no_css,
4442 css_deep,
4443 no_css_deep,
4444 gate,
4445 runtime_coverage,
4446 min_invocations_hot,
4447 gate_marker,
4448 brief,
4449 max_decisions,
4450 walkthrough_guide,
4451 walkthrough_file,
4452 walkthrough,
4453 mark_viewed,
4454 show_cleared,
4455 show_deprioritized,
4456 } = command
4457 else {
4458 unreachable!("audit dispatcher only handles audit commands");
4459 };
4460
4461 let brief = brief || walkthrough_guide || walkthrough || walkthrough_file.is_some();
4464
4465 dispatch_audit(
4466 dispatch,
4467 &AuditDispatchArgs {
4468 production_dead_code,
4469 production_health,
4470 production_dupes,
4471 dead_code_baseline,
4472 health_baseline,
4473 dupes_baseline,
4474 max_crap,
4475 coverage,
4476 coverage_root,
4477 no_css,
4478 css_deep,
4479 no_css_deep,
4480 gate,
4481 runtime_coverage,
4482 min_invocations_hot,
4483 gate_marker,
4484 brief,
4485 max_decisions,
4486 walkthrough_guide,
4487 walkthrough_file,
4488 walkthrough,
4489 mark_viewed,
4490 show_cleared,
4491 show_deprioritized,
4492 },
4493 )
4494}
4495
4496fn dispatch_audit_cache_command(
4497 dispatch: &DispatchContext<'_>,
4498 subcommand: &AuditCacheCli,
4499) -> ExitCode {
4500 match subcommand {
4501 AuditCacheCli::Remove { dry_run, yes } => {
4502 if !*dry_run && !*yes && !std::io::stdin().is_terminal() {
4503 return emit_error(
4504 "audit-cache remove requires --yes (or --force) in non-interactive environments. Use --dry-run to preview removal first, then pass --yes to confirm.",
4505 2,
4506 dispatch.output,
4507 );
4508 }
4509 match base_worktree::remove_reusable_audit_caches(dispatch.root, *dry_run) {
4510 Ok(report) => {
4511 let action = if *dry_run { "would remove" } else { "removed" };
4512 if matches!(dispatch.output, fallow_config::OutputFormat::Json) {
4513 let value = serde_json::json!({
4514 "kind": "audit-cache-remove",
4515 "schema_version": 1,
4516 "command": "audit-cache remove",
4517 "root": dispatch.root,
4518 "dry_run": report.dry_run,
4519 "found": report.found,
4520 "would_remove": report.found.saturating_sub(report.skipped),
4521 "removed": report.removed,
4522 "skipped": report.skipped,
4523 "complete": report.skipped == 0,
4524 });
4525 let output_code = report::emit_report_json(
4526 &value,
4527 "audit cache removal",
4528 dispatch.json_style,
4529 );
4530 if output_code != ExitCode::SUCCESS {
4531 return output_code;
4532 }
4533 } else if !dispatch.quiet {
4534 println!(
4535 "audit cache: {action} {}, skipped {} for {}",
4536 if *dry_run {
4537 report.found.saturating_sub(report.skipped)
4538 } else {
4539 report.removed
4540 },
4541 report.skipped,
4542 dispatch.root.display(),
4543 );
4544 }
4545 if report.skipped == 0 {
4546 ExitCode::SUCCESS
4547 } else {
4548 ExitCode::from(2)
4549 }
4550 }
4551 Err(error) => emit_error(
4552 &format!(
4553 "failed to remove audit caches for {}: {error}",
4554 dispatch.root.display()
4555 ),
4556 2,
4557 dispatch.output,
4558 ),
4559 }
4560 }
4561 AuditCacheCli::Prune {
4562 dry_run,
4563 max_age_days,
4564 } => audit_cache_prune::run_audit_cache_prune(&audit_cache_prune::AuditCachePruneOptions {
4565 root: dispatch.root,
4566 config_path: dispatch.cli.config.as_ref(),
4567 allow_remote_extends: dispatch.cli.allow_remote_extends,
4568 dry_run: *dry_run,
4569 max_age_days: *max_age_days,
4570 output: dispatch.output,
4571 json_style: dispatch.json_style,
4572 quiet: dispatch.quiet,
4573 }),
4574 }
4575}
4576
4577fn dispatch_flags_command(dispatch: &DispatchContext<'_>, top: Option<usize>) -> ExitCode {
4578 let cli = dispatch.cli;
4579 let root = dispatch.root;
4580 let output = dispatch.output;
4581 let quiet = dispatch.quiet;
4582 let threads = dispatch.threads;
4583 let production = match resolve_production_modes(cli, root, output, false, false, false) {
4584 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::DeadCode),
4585 Err(code) => return code,
4586 };
4587 flags::run_flags(&flags::FlagsOptions {
4588 root,
4589 config_path: &cli.config,
4590 output,
4591 json_style: dispatch.json_style,
4592 no_cache: cli.no_cache,
4593 threads,
4594 quiet,
4595 allow_remote_extends: cli.allow_remote_extends,
4596 production,
4597 workspace: cli.workspace.as_deref(),
4598 changed_workspaces: cli.changed_workspaces.as_deref(),
4599 changed_since: cli.changed_since.as_deref(),
4600 explain: cli.explain,
4601 top,
4602 })
4603}
4604
4605fn dispatch_suppressions_command(
4606 dispatch: &DispatchContext<'_>,
4607 file: &[std::path::PathBuf],
4608) -> ExitCode {
4609 let cli = dispatch.cli;
4610 let root = dispatch.root;
4611 let output = dispatch.output;
4612 let production = match resolve_production_modes(cli, root, output, false, false, false) {
4613 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::DeadCode),
4614 Err(code) => return code,
4615 };
4616 suppressions::run_suppressions(&suppressions::SuppressionsOptions {
4617 root,
4618 config_path: &cli.config,
4619 output,
4620 json_style: dispatch.json_style,
4621 no_cache: cli.no_cache,
4622 threads: dispatch.threads,
4623 quiet: dispatch.quiet,
4624 allow_remote_extends: cli.allow_remote_extends,
4625 production,
4626 workspace: cli.workspace.as_deref(),
4627 changed_workspaces: cli.changed_workspaces.as_deref(),
4628 changed_since: cli.changed_since.as_deref(),
4629 file,
4630 })
4631}
4632
4633fn dispatch_guard_command(dispatch: &DispatchContext<'_>, files: &[String]) -> ExitCode {
4634 guard::run_guard(&guard::GuardOptions {
4635 root: dispatch.root,
4636 config_path: &dispatch.cli.config,
4637 output: dispatch.output,
4638 json_style: dispatch.json_style,
4639 quiet: dispatch.quiet,
4640 allow_remote_extends: dispatch.cli.allow_remote_extends,
4641 files,
4642 })
4643}
4644
4645fn dispatch_rule_pack_command(dispatch: &DispatchContext<'_>, subcommand: RulePackCli) -> ExitCode {
4646 let ctx = rule_pack::RulePackContext {
4647 root: dispatch.root,
4648 config_path: &dispatch.cli.config,
4649 output: dispatch.output,
4650 json_style: dispatch.json_style,
4651 quiet: dispatch.quiet,
4652 no_cache: dispatch.cli.no_cache,
4653 threads: Some(dispatch.threads),
4654 allow_remote_extends: dispatch.cli.allow_remote_extends,
4655 };
4656 rule_pack::run(&map_rule_pack_subcommand(subcommand), &ctx)
4657}
4658
4659fn map_rule_pack_subcommand(subcommand: RulePackCli) -> rule_pack::RulePackSubcommand {
4660 match subcommand {
4661 RulePackCli::Init {
4662 name,
4663 template,
4664 dir,
4665 no_config,
4666 } => rule_pack::RulePackSubcommand::Init(rule_pack::InitArgs {
4667 name,
4668 template,
4669 dir,
4670 no_config,
4671 }),
4672 RulePackCli::List => rule_pack::RulePackSubcommand::List,
4673 RulePackCli::Test { pack } => {
4674 rule_pack::RulePackSubcommand::Test(rule_pack::TestArgs { pack })
4675 }
4676 RulePackCli::Schema => rule_pack::RulePackSubcommand::Schema,
4677 }
4678}
4679
4680fn map_license_subcommand(sub: LicenseCli) -> license::LicenseSubcommand {
4681 match sub {
4682 LicenseCli::Activate {
4683 jwt,
4684 from_file,
4685 stdin,
4686 trial,
4687 email,
4688 } => license::LicenseSubcommand::Activate(license::ActivateArgs {
4689 raw_jwt: jwt,
4690 from_file,
4691 from_stdin: stdin,
4692 trial,
4693 email,
4694 }),
4695 LicenseCli::Status => license::LicenseSubcommand::Status,
4696 LicenseCli::Refresh => license::LicenseSubcommand::Refresh,
4697 LicenseCli::Deactivate => license::LicenseSubcommand::Deactivate,
4698 }
4699}
4700
4701fn map_telemetry_subcommand(sub: TelemetryCli) -> telemetry::TelemetryCommand {
4702 match sub {
4703 TelemetryCli::Status => telemetry::TelemetryCommand::Status,
4704 TelemetryCli::Enable => telemetry::TelemetryCommand::Enable,
4705 TelemetryCli::Disable => telemetry::TelemetryCommand::Disable,
4706 TelemetryCli::Inspect { example } => telemetry::TelemetryCommand::Inspect { example },
4707 }
4708}
4709
4710fn map_ci_subcommand(sub: CiCli) -> ci::CiCommand {
4711 match sub {
4712 command @ CiCli::PlanPrComment { .. } => map_ci_plan_pr_comment(command),
4713 command @ CiCli::PostPrComment { .. } => map_ci_post_pr_comment(command),
4714 command @ CiCli::PostReview { .. } => map_ci_post_review(command),
4715 command @ CiCli::PostCheckRun { .. } => map_ci_post_check_run(command),
4716 command @ CiCli::ReconcileReview { .. } => map_ci_reconcile_review(command),
4717 }
4718}
4719
4720fn map_ci_plan_pr_comment(command: CiCli) -> ci::CiCommand {
4721 let CiCli::PlanPrComment {
4722 body,
4723 marker_id,
4724 clean,
4725 existing_comment_id,
4726 existing_body,
4727 } = command
4728 else {
4729 unreachable!("ci plan-pr-comment mapper called with different variant");
4730 };
4731
4732 ci::CiCommand::PlanPrComment {
4733 body,
4734 marker_id,
4735 clean,
4736 existing_comment_id,
4737 existing_body,
4738 }
4739}
4740
4741fn map_ci_post_pr_comment(command: CiCli) -> ci::CiCommand {
4742 let CiCli::PostPrComment {
4743 provider,
4744 pr,
4745 mr,
4746 body,
4747 envelope,
4748 marker_id,
4749 clean,
4750 repo,
4751 project_id,
4752 api_url,
4753 dry_run,
4754 } = command
4755 else {
4756 unreachable!("ci post-pr-comment mapper called with different variant");
4757 };
4758
4759 ci::CiCommand::PostPrComment {
4760 provider: map_ci_provider(provider),
4761 target: pr.or(mr),
4762 body,
4763 envelope,
4764 marker_id,
4765 clean,
4766 repo,
4767 project_id,
4768 api_url,
4769 dry_run,
4770 }
4771}
4772
4773fn map_ci_post_review(command: CiCli) -> ci::CiCommand {
4774 let CiCli::PostReview {
4775 provider,
4776 pr,
4777 mr,
4778 envelope,
4779 repo,
4780 project_id,
4781 api_url,
4782 dry_run,
4783 } = command
4784 else {
4785 unreachable!("ci post-review mapper called with different variant");
4786 };
4787
4788 ci::CiCommand::PostReview {
4789 provider: map_ci_provider(provider),
4790 target: pr.or(mr),
4791 envelope,
4792 repo,
4793 project_id,
4794 api_url,
4795 dry_run,
4796 }
4797}
4798
4799fn map_ci_post_check_run(command: CiCli) -> ci::CiCommand {
4800 let CiCli::PostCheckRun {
4801 provider,
4802 decision,
4803 repo,
4804 head_sha,
4805 api_url,
4806 split_gates,
4807 dry_run,
4808 } = command
4809 else {
4810 unreachable!("ci post-check-run mapper called with different variant");
4811 };
4812
4813 ci::CiCommand::PostCheckRun {
4814 provider: map_ci_provider(provider),
4815 decision,
4816 repo,
4817 head_sha,
4818 api_url,
4819 split_gates,
4820 dry_run,
4821 }
4822}
4823
4824fn map_ci_reconcile_review(command: CiCli) -> ci::CiCommand {
4825 let CiCli::ReconcileReview {
4826 provider,
4827 pr,
4828 mr,
4829 envelope,
4830 repo,
4831 project_id,
4832 api_url,
4833 dry_run,
4834 } = command
4835 else {
4836 unreachable!("ci reconcile-review mapper called with different variant");
4837 };
4838
4839 ci::CiCommand::ReconcileReview {
4840 provider: map_ci_provider(provider),
4841 target: pr.or(mr),
4842 envelope,
4843 repo,
4844 project_id,
4845 api_url,
4846 dry_run,
4847 }
4848}
4849
4850fn map_ci_provider(provider: CiProviderArg) -> ci::CiProvider {
4851 match provider {
4852 CiProviderArg::Github => ci::CiProvider::Github,
4853 CiProviderArg::Gitlab => ci::CiProvider::Gitlab,
4854 }
4855}
4856
4857fn map_coverage_subcommand(sub: &CoverageCli, explain: bool) -> coverage::CoverageSubcommand {
4858 match sub {
4859 CoverageCli::Setup {
4860 yes,
4861 non_interactive,
4862 json,
4863 } => map_coverage_setup(*yes, *non_interactive, *json, explain),
4864 CoverageCli::Analyze { .. } => map_coverage_analyze(sub),
4865 CoverageCli::UploadInventory { .. } => map_coverage_upload_inventory(sub),
4866 CoverageCli::UploadSourceMaps { .. } => map_coverage_upload_source_maps(sub),
4867 CoverageCli::UploadStaticFindings { .. } => map_coverage_upload_static_findings(sub),
4868 }
4869}
4870
4871fn map_coverage_setup(
4872 yes: bool,
4873 non_interactive: bool,
4874 json: bool,
4875 explain: bool,
4876) -> coverage::CoverageSubcommand {
4877 coverage::CoverageSubcommand::Setup(coverage::SetupArgs {
4878 yes,
4879 non_interactive: non_interactive || json,
4880 json,
4881 explain,
4882 })
4883}
4884
4885fn map_coverage_analyze(sub: &CoverageCli) -> coverage::CoverageSubcommand {
4886 let CoverageCli::Analyze {
4887 runtime_coverage,
4888 cloud,
4889 api_key,
4890 api_endpoint,
4891 repo,
4892 project_id,
4893 coverage_period,
4894 environment,
4895 commit_sha,
4896 production,
4897 min_invocations_hot,
4898 min_observation_volume,
4899 low_traffic_threshold,
4900 top,
4901 blast_radius,
4902 importance,
4903 } = sub
4904 else {
4905 unreachable!("coverage analyze mapper called with non-analyze variant");
4906 };
4907 coverage::CoverageSubcommand::Analyze(coverage::AnalyzeArgs {
4908 runtime_coverage: runtime_coverage.clone(),
4909 cloud: *cloud,
4910 api_key: api_key.clone(),
4911 api_endpoint: api_endpoint.clone(),
4912 repo: repo.clone(),
4913 project_id: project_id.clone(),
4914 coverage_period: *coverage_period,
4915 environment: environment.clone(),
4916 commit_sha: commit_sha.clone(),
4917 production: *production,
4918 min_invocations_hot: *min_invocations_hot,
4919 min_observation_volume: *min_observation_volume,
4920 low_traffic_threshold: *low_traffic_threshold,
4921 top: *top,
4922 blast_radius: *blast_radius,
4923 importance: *importance,
4924 })
4925}
4926
4927fn map_coverage_upload_inventory(sub: &CoverageCli) -> coverage::CoverageSubcommand {
4928 let CoverageCli::UploadInventory {
4929 api_key,
4930 api_endpoint,
4931 project_id,
4932 git_sha,
4933 allow_dirty,
4934 exclude_paths,
4935 path_prefix,
4936 dry_run,
4937 with_callers,
4938 ignore_upload_errors,
4939 } = sub
4940 else {
4941 unreachable!("coverage inventory mapper called with non-inventory variant");
4942 };
4943 coverage::CoverageSubcommand::UploadInventory(coverage::UploadInventoryArgs {
4944 api_key: api_key.clone(),
4945 api_endpoint: api_endpoint.clone(),
4946 project_id: project_id.clone(),
4947 git_sha: git_sha.clone(),
4948 allow_dirty: *allow_dirty,
4949 exclude_paths: exclude_paths.clone(),
4950 path_prefix: path_prefix.clone(),
4951 dry_run: *dry_run,
4952 with_callers: *with_callers,
4953 ignore_upload_errors: *ignore_upload_errors,
4954 })
4955}
4956
4957fn map_coverage_upload_source_maps(sub: &CoverageCli) -> coverage::CoverageSubcommand {
4958 let CoverageCli::UploadSourceMaps {
4959 dir,
4960 include,
4961 exclude,
4962 repo,
4963 git_sha,
4964 endpoint,
4965 strip_path,
4966 dry_run,
4967 concurrency,
4968 fail_fast,
4969 } = sub
4970 else {
4971 unreachable!("coverage source-map mapper called with non-source-map variant");
4972 };
4973 coverage::CoverageSubcommand::UploadSourceMaps(coverage::UploadSourceMapsArgs {
4974 dir: dir.clone(),
4975 include: include.clone(),
4976 exclude: exclude.clone(),
4977 repo: repo.clone(),
4978 git_sha: git_sha.clone(),
4979 endpoint: endpoint.clone(),
4980 strip_path: *strip_path,
4981 dry_run: *dry_run,
4982 concurrency: *concurrency,
4983 fail_fast: *fail_fast,
4984 })
4985}
4986
4987fn map_coverage_upload_static_findings(sub: &CoverageCli) -> coverage::CoverageSubcommand {
4988 let CoverageCli::UploadStaticFindings {
4989 api_key,
4990 api_endpoint,
4991 project_id,
4992 git_sha,
4993 allow_dirty,
4994 dry_run,
4995 ignore_upload_errors,
4996 } = sub
4997 else {
4998 unreachable!("coverage static-findings mapper called with non-static variant");
4999 };
5000 coverage::CoverageSubcommand::UploadStaticFindings(coverage::UploadStaticFindingsArgs {
5001 api_key: api_key.clone(),
5002 api_endpoint: api_endpoint.clone(),
5003 project_id: project_id.clone(),
5004 git_sha: git_sha.clone(),
5005 allow_dirty: *allow_dirty,
5006 dry_run: *dry_run,
5007 ignore_upload_errors: *ignore_upload_errors,
5008 })
5009}
5010
5011struct CheckDispatchArgs {
5012 filters: IssueFilters,
5013 trace_opts: TraceOptions,
5014 include_dupes: bool,
5015 type_aware: Option<bool>,
5016 type_aware_project: Vec<std::path::PathBuf>,
5017 type_aware_require: Option<TypeAwareRequireArg>,
5018 top: Option<usize>,
5019 file: Vec<std::path::PathBuf>,
5020}
5021
5022#[derive(Clone, Copy)]
5023struct ListDispatchArgs {
5024 entry_points: bool,
5025 files: bool,
5026 plugins: bool,
5027 boundaries: bool,
5028 workspaces: bool,
5029}
5030
5031impl ListDispatchArgs {
5032 fn workspaces() -> Self {
5033 Self {
5034 entry_points: false,
5035 files: false,
5036 plugins: false,
5037 boundaries: false,
5038 workspaces: true,
5039 }
5040 }
5041}
5042
5043fn dispatch_viz(
5044 dispatch: &DispatchContext<'_>,
5045 output_path: Option<&std::path::Path>,
5046 no_open: bool,
5047 format: viz::VizFormat,
5048) -> ExitCode {
5049 let cli = dispatch.cli;
5050 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
5051 Ok(production) => production,
5052 Err(code) => return code,
5053 };
5054 viz::run_viz(&viz::VizOptions {
5055 root: dispatch.root,
5056 config_path: &cli.config,
5057 no_cache: cli.no_cache,
5058 threads: dispatch.threads,
5059 quiet: dispatch.quiet,
5060 production,
5061 allow_remote_extends: cli.allow_remote_extends,
5062 output_path,
5063 no_open,
5064 format,
5065 })
5066}
5067
5068fn dispatch_watch(dispatch: &DispatchContext<'_>, no_clear: bool) -> ExitCode {
5069 let cli = dispatch.cli;
5070 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
5071 Ok(production) => production,
5072 Err(code) => return code,
5073 };
5074 watch::run_watch(&watch::WatchOptions {
5075 root: dispatch.root,
5076 config_path: &cli.config,
5077 output: dispatch.output,
5078 json_style: dispatch.json_style,
5079 no_cache: cli.no_cache,
5080 threads: dispatch.threads,
5081 quiet: dispatch.quiet,
5082 allow_remote_extends: cli.allow_remote_extends,
5083 production,
5084 clear_screen: !no_clear,
5085 explain: cli.explain,
5086 include_entry_exports: cli.include_entry_exports,
5087 type_aware: cli.type_aware_override(),
5088 type_aware_projects: &cli.type_aware_project,
5089 type_aware_require: cli.type_aware_require.map(Into::into),
5090 })
5091}
5092
5093#[derive(Clone, Copy)]
5094struct FixDispatchArgs {
5095 dry_run: bool,
5096 yes: bool,
5097 no_create_config: bool,
5098}
5099
5100fn dispatch_fix(dispatch: &DispatchContext<'_>, args: FixDispatchArgs) -> ExitCode {
5101 let cli = dispatch.cli;
5102 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
5103 Ok(production) => production,
5104 Err(code) => return code,
5105 };
5106 fix::run_fix(&fix::FixOptions {
5107 root: dispatch.root,
5108 config_path: &cli.config,
5109 output: dispatch.output,
5110 json_style: dispatch.json_style,
5111 no_cache: cli.no_cache,
5112 threads: dispatch.threads,
5113 quiet: dispatch.quiet,
5114 emit_output: true,
5115 allow_remote_extends: cli.allow_remote_extends,
5116 dry_run: args.dry_run,
5117 yes: args.yes,
5118 production,
5119 no_create_config: args.no_create_config,
5120 type_aware: cli.type_aware_override(),
5121 type_aware_projects: &cli.type_aware_project,
5122 type_aware_require: cli.type_aware_require.map(Into::into),
5123 })
5124}
5125
5126fn dispatch_list(dispatch: &DispatchContext<'_>, args: ListDispatchArgs) -> ExitCode {
5127 let cli = dispatch.cli;
5128 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
5129 Ok(production) => production,
5130 Err(code) => return code,
5131 };
5132 list::run_list(&ListOptions {
5133 root: dispatch.root,
5134 config_path: &cli.config,
5135 output: dispatch.output,
5136 json_style: dispatch.json_style,
5137 threads: dispatch.threads,
5138 no_cache: cli.no_cache,
5139 entry_points: args.entry_points,
5140 files: args.files,
5141 plugins: args.plugins,
5142 boundaries: args.boundaries,
5143 workspaces: args.workspaces,
5144 production,
5145 allow_remote_extends: cli.allow_remote_extends,
5146 })
5147}
5148
5149fn dispatch_check(dispatch: &DispatchContext<'_>, args: &CheckDispatchArgs) -> ExitCode {
5150 let cli = dispatch.cli;
5151 let (output, quiet, fail_on_issues) =
5152 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
5153 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
5154 Ok(production) => production,
5155 Err(code) => return code,
5156 };
5157 if let Some(code) = validate_type_aware_check_options(dispatch, args) {
5158 return code;
5159 }
5160 check::run_check(&CheckOptions {
5161 root: dispatch.root,
5162 config_path: &cli.config,
5163 output,
5164 json_style: dispatch.json_style,
5165 no_cache: cli.no_cache,
5166 threads: dispatch.threads,
5167 quiet,
5168 allow_remote_extends: cli.allow_remote_extends,
5169 fail_on_issues,
5170 filters: &args.filters,
5171 changed_since: cli.changed_since.as_deref(),
5172 diff_index: None,
5173 use_shared_diff_index: true,
5174 baseline: cli.baseline.as_deref(),
5175 save_baseline: cli.save_baseline.as_deref(),
5176 sarif_file: cli.sarif_file.as_deref(),
5177 production,
5178 production_override: Some(production),
5179 workspace: cli.workspace.as_deref(),
5180 changed_workspaces: cli.changed_workspaces.as_deref(),
5181 group_by: cli.group_by,
5182 include_dupes: args.include_dupes,
5183 type_aware: args.type_aware,
5184 type_aware_config_override: None,
5185 type_aware_projects: &args.type_aware_project,
5186 type_aware_require: args.type_aware_require.map(Into::into),
5187 trace_opts: &args.trace_opts,
5188 explain: cli.explain,
5189 top: args.top,
5190 file: &args.file,
5191 include_entry_exports: cli.include_entry_exports,
5192 summary: cli.summary,
5193 regression_opts: dispatch.regression_opts(
5194 cli.changed_since.is_some()
5195 || cli.workspace.is_some()
5196 || cli.changed_workspaces.is_some()
5197 || !args.file.is_empty(),
5198 ),
5199 retain_modules_for_health: false,
5200 defer_performance: false,
5201 analysis_snapshot: fallow_config::AnalysisSnapshot::Current,
5202 })
5203}
5204
5205fn validate_type_aware_check_options(
5206 dispatch: &DispatchContext<'_>,
5207 args: &CheckDispatchArgs,
5208) -> Option<ExitCode> {
5209 let output = dispatch.output;
5210 if !args.type_aware_project.is_empty() && args.type_aware != Some(true) {
5211 return Some(emit_error(
5212 "--type-aware-project requires --type-aware",
5213 2,
5214 output,
5215 ));
5216 }
5217 if args.type_aware_require.is_some() && args.type_aware != Some(true) {
5218 return Some(emit_error(
5219 "--type-aware-require requires --type-aware",
5220 2,
5221 output,
5222 ));
5223 }
5224 if args.trace_opts.symbol_impact.is_some() && args.type_aware != Some(true) {
5225 return Some(emit_error(
5226 "--symbol-impact requires --type-aware",
5227 2,
5228 output,
5229 ));
5230 }
5231 let focused_output = args.trace_opts.trace_export.is_some()
5232 || args.trace_opts.trace_file.is_some()
5233 || args.trace_opts.trace_dependency.is_some()
5234 || args.trace_opts.impact_closure.is_some()
5235 || args.trace_opts.symbol_impact.is_some();
5236 if focused_output
5237 && !matches!(
5238 output,
5239 fallow_config::OutputFormat::Human | fallow_config::OutputFormat::Json
5240 )
5241 {
5242 return Some(emit_error(
5243 "focused trace and impact queries support human and JSON output",
5244 2,
5245 output,
5246 ));
5247 }
5248 if args.type_aware == Some(true)
5249 && !matches!(
5250 output,
5251 fallow_config::OutputFormat::Human
5252 | fallow_config::OutputFormat::Json
5253 | fallow_config::OutputFormat::Sarif
5254 | fallow_config::OutputFormat::Compact
5255 | fallow_config::OutputFormat::Markdown
5256 | fallow_config::OutputFormat::CodeClimate
5257 | fallow_config::OutputFormat::PrCommentGithub
5258 | fallow_config::OutputFormat::PrCommentGitlab
5259 | fallow_config::OutputFormat::ReviewGithub
5260 | fallow_config::OutputFormat::ReviewGitlab
5261 )
5262 {
5263 return Some(emit_error(
5264 "--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",
5265 2,
5266 output,
5267 ));
5268 }
5269 None
5270}
5271
5272fn resolve_ignore_imports(ignore_imports: bool, no_ignore_imports: bool) -> Option<bool> {
5278 if no_ignore_imports {
5279 Some(false)
5280 } else if ignore_imports {
5281 Some(true)
5282 } else {
5283 None
5284 }
5285}
5286
5287struct DupesDispatchArgs {
5288 mode: Option<DupesMode>,
5289 near: bool,
5290 min_tokens: Option<usize>,
5291 min_lines: Option<usize>,
5292 min_occurrences: Option<usize>,
5293 threshold: Option<f64>,
5294 skip_local: bool,
5295 cross_language: bool,
5296 ignore_imports: bool,
5297 no_ignore_imports: bool,
5298 top: Option<usize>,
5299 trace: Option<String>,
5300}
5301
5302fn dispatch_dupes(dispatch: &DispatchContext<'_>, args: &DupesDispatchArgs) -> ExitCode {
5303 let cli = dispatch.cli;
5304 let (output, quiet, _fail_on_issues) =
5305 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
5306 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::Dupes) {
5307 Ok(production) => production,
5308 Err(code) => return code,
5309 };
5310 dupes::run_dupes(&DupesOptions {
5311 root: dispatch.root,
5312 config_path: &cli.config,
5313 output,
5314 json_style: dispatch.json_style,
5315 no_cache: cli.no_cache,
5316 threads: dispatch.threads,
5317 quiet,
5318 allow_remote_extends: cli.allow_remote_extends,
5319 mode: args.mode,
5320 near: args.near,
5321 min_tokens: args.min_tokens,
5322 min_lines: args.min_lines,
5323 min_occurrences: args.min_occurrences,
5324 threshold: args.threshold,
5325 skip_local: args.skip_local,
5326 cross_language: args.cross_language,
5327 ignore_imports: resolve_ignore_imports(args.ignore_imports, args.no_ignore_imports),
5328 top: args.top,
5329 baseline_path: cli.baseline.as_deref(),
5330 save_baseline_path: cli.save_baseline.as_deref(),
5331 production,
5332 production_override: Some(production),
5333 trace: args.trace.as_deref(),
5334 changed_since: cli.changed_since.as_deref(),
5335 diff_index: None,
5336 use_shared_diff_index: true,
5337 changed_files: None,
5338 workspace: cli.workspace.as_deref(),
5339 changed_workspaces: cli.changed_workspaces.as_deref(),
5340 explain: cli.explain,
5341 explain_skipped: cli.explain_skipped,
5342 summary: cli.summary,
5343 group_by: cli.group_by,
5344 performance: cli.performance,
5345 })
5346}
5347
5348struct AuditDispatchArgs {
5349 production_dead_code: bool,
5350 production_health: bool,
5351 production_dupes: bool,
5352 dead_code_baseline: Option<PathBuf>,
5353 health_baseline: Option<PathBuf>,
5354 dupes_baseline: Option<PathBuf>,
5355 max_crap: Option<f64>,
5356 coverage: Option<PathBuf>,
5357 coverage_root: Option<PathBuf>,
5358 no_css: bool,
5359 css_deep: bool,
5360 no_css_deep: bool,
5361 gate: Option<AuditGateArg>,
5362 runtime_coverage: Option<PathBuf>,
5363 min_invocations_hot: u64,
5364 gate_marker: Option<String>,
5365 brief: bool,
5366 max_decisions: usize,
5367 walkthrough_guide: bool,
5369 walkthrough_file: Option<PathBuf>,
5372 walkthrough: bool,
5374 mark_viewed: Vec<PathBuf>,
5376 show_cleared: bool,
5378 show_deprioritized: bool,
5380}
5381
5382struct ResolvedAuditInputs {
5383 audit_cfg: fallow_config::AuditConfig,
5384 cache_dir: PathBuf,
5385 production: ProductionModes,
5386 dead_code_baseline: Option<PathBuf>,
5387 health_baseline: Option<PathBuf>,
5388 dupes_baseline: Option<PathBuf>,
5389 coverage: Option<PathBuf>,
5393 coverage_root: Option<PathBuf>,
5394}
5395
5396fn dispatch_audit(dispatch: &DispatchContext<'_>, args: &AuditDispatchArgs) -> ExitCode {
5397 let cli = dispatch.cli;
5398 let output = dispatch.output;
5399
5400 if cli.baseline.is_some() || cli.save_baseline.is_some() {
5401 return emit_error(
5402 "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>`)",
5403 2,
5404 output,
5405 );
5406 }
5407
5408 let inputs = match resolve_audit_inputs(dispatch, args) {
5409 Ok(inputs) => inputs,
5410 Err(code) => return code,
5411 };
5412
5413 run_resolved_audit(dispatch, args, &inputs)
5414}
5415
5416fn resolve_audit_inputs(
5417 dispatch: &DispatchContext<'_>,
5418 args: &AuditDispatchArgs,
5419) -> Result<ResolvedAuditInputs, ExitCode> {
5420 let cli = dispatch.cli;
5421 let root = dispatch.root;
5422 let output = dispatch.output;
5423 let config = load_config(
5424 root,
5425 &cli.config,
5426 LoadConfigArgs {
5427 output,
5428 no_cache: cli.no_cache,
5429 threads: dispatch.threads,
5430 production: cli.production,
5431 quiet: dispatch.quiet,
5432 allow_remote_extends: cli.allow_remote_extends,
5433 },
5434 )?;
5435 let cache_dir = config.cache_dir.clone();
5436 let audit_cfg = config.audit;
5437 let production = resolve_production_modes(
5438 cli,
5439 root,
5440 output,
5441 args.production_dead_code,
5442 args.production_health,
5443 args.production_dupes,
5444 )?;
5445 let resolved_dead_code_baseline = resolve_audit_baseline_path(
5446 root,
5447 args.dead_code_baseline.as_deref(),
5448 audit_cfg.dead_code_baseline.as_deref(),
5449 );
5450 let resolved_health_baseline = resolve_audit_baseline_path(
5451 root,
5452 args.health_baseline.as_deref(),
5453 audit_cfg.health_baseline.as_deref(),
5454 );
5455 let resolved_dupes_baseline = resolve_audit_baseline_path(
5456 root,
5457 args.dupes_baseline.as_deref(),
5458 audit_cfg.dupes_baseline.as_deref(),
5459 );
5460 let coverage_inputs = resolve_coverage_inputs(
5461 args.coverage.as_deref(),
5462 args.coverage_root.as_deref(),
5463 output,
5464 || Ok(config.health),
5465 )?;
5466
5467 Ok(ResolvedAuditInputs {
5468 audit_cfg,
5469 cache_dir,
5470 production,
5471 dead_code_baseline: resolved_dead_code_baseline,
5472 health_baseline: resolved_health_baseline,
5473 dupes_baseline: resolved_dupes_baseline,
5474 coverage: coverage_inputs.coverage,
5475 coverage_root: coverage_inputs.coverage_root,
5476 })
5477}
5478
5479fn audit_css_enabled(config: &fallow_config::AuditConfig, args: &AuditDispatchArgs) -> bool {
5480 !args.no_css && config.css.unwrap_or(true)
5481}
5482
5483fn audit_css_deep_enabled(config: &fallow_config::AuditConfig, args: &AuditDispatchArgs) -> bool {
5484 audit_css_enabled(config, args)
5485 && !args.no_css_deep
5486 && (args.css_deep || config.css_deep.unwrap_or(true))
5487}
5488
5489fn run_resolved_audit(
5490 dispatch: &DispatchContext<'_>,
5491 args: &AuditDispatchArgs,
5492 inputs: &ResolvedAuditInputs,
5493) -> ExitCode {
5494 let cli = dispatch.cli;
5495 audit::run_audit_with_type_aware(
5496 &audit::AuditOptions {
5497 root: dispatch.root,
5498 config_path: &cli.config,
5499 cache_dir: &inputs.cache_dir,
5500 output: dispatch.output,
5501 json_style: dispatch.json_style,
5502 no_cache: cli.no_cache,
5503 threads: dispatch.threads,
5504 quiet: dispatch.quiet,
5505 allow_remote_extends: cli.allow_remote_extends,
5506 changed_since: cli.changed_since.as_deref(),
5507 production: cli.production,
5508 production_dead_code: Some(inputs.production.dead_code),
5509 production_health: Some(inputs.production.health),
5510 production_dupes: Some(inputs.production.dupes),
5511 workspace: cli.workspace.as_deref(),
5512 changed_workspaces: cli.changed_workspaces.as_deref(),
5513 explain: cli.explain,
5514 explain_skipped: cli.explain_skipped,
5515 performance: cli.performance,
5516 group_by: cli.group_by,
5517 dead_code_baseline: inputs.dead_code_baseline.as_deref(),
5518 health_baseline: inputs.health_baseline.as_deref(),
5519 dupes_baseline: inputs.dupes_baseline.as_deref(),
5520 health_baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
5521 max_crap: args.max_crap,
5522 coverage: inputs.coverage.as_deref(),
5523 coverage_root: inputs.coverage_root.as_deref(),
5524 gate: args.gate.map_or(inputs.audit_cfg.gate, Into::into),
5525 include_entry_exports: cli.include_entry_exports,
5526 css: audit_css_enabled(&inputs.audit_cfg, args),
5530 css_deep: audit_css_deep_enabled(&inputs.audit_cfg, args),
5531 runtime_coverage: args.runtime_coverage.as_deref(),
5532 min_invocations_hot: args.min_invocations_hot,
5533 brief: args.brief,
5534 max_decisions: args.max_decisions,
5535 walkthrough_guide: args.walkthrough_guide,
5536 walkthrough: args.walkthrough,
5537 mark_viewed: &args.mark_viewed,
5538 show_cleared: args.show_cleared,
5539 walkthrough_file: args.walkthrough_file.as_deref(),
5540 show_deprioritized: args.show_deprioritized,
5541 },
5542 args.gate_marker.as_deref(),
5543 audit::AuditTypeAwareOptions {
5544 enabled: cli.type_aware_override(),
5545 config_default: inputs.audit_cfg.type_aware,
5546 projects: &cli.type_aware_project,
5547 require: cli.type_aware_require.map(Into::into),
5548 },
5549 )
5550}
5551
5552fn dispatch_decision_surface(dispatch: &DispatchContext<'_>, max_decisions: usize) -> ExitCode {
5556 let args = decision_surface_audit_args(max_decisions);
5557 let inputs = match resolve_audit_inputs(dispatch, &args) {
5558 Ok(inputs) => inputs,
5559 Err(code) => return code,
5560 };
5561 audit::run_decision_surface(&decision_surface_audit_options(
5562 dispatch,
5563 &inputs,
5564 max_decisions,
5565 ))
5566}
5567
5568fn decision_surface_audit_args(max_decisions: usize) -> AuditDispatchArgs {
5569 AuditDispatchArgs {
5570 production_dead_code: false,
5571 production_health: false,
5572 production_dupes: false,
5573 dead_code_baseline: None,
5574 health_baseline: None,
5575 dupes_baseline: None,
5576 max_crap: None,
5577 coverage: None,
5578 coverage_root: None,
5579 no_css: true,
5580 css_deep: false,
5581 no_css_deep: false,
5582 gate: None,
5583 runtime_coverage: None,
5584 min_invocations_hot: 0,
5585 gate_marker: None,
5586 brief: true,
5587 max_decisions,
5588 walkthrough_guide: false,
5589 walkthrough_file: None,
5590 walkthrough: false,
5591 mark_viewed: Vec::new(),
5592 show_cleared: false,
5593 show_deprioritized: false,
5594 }
5595}
5596
5597fn decision_surface_audit_options<'a>(
5598 dispatch: &'a DispatchContext<'a>,
5599 inputs: &'a ResolvedAuditInputs,
5600 max_decisions: usize,
5601) -> audit::AuditOptions<'a> {
5602 let cli = dispatch.cli;
5603 audit::AuditOptions {
5604 root: dispatch.root,
5605 config_path: &cli.config,
5606 cache_dir: &inputs.cache_dir,
5607 output: dispatch.output,
5608 json_style: dispatch.json_style,
5609 no_cache: cli.no_cache,
5610 threads: dispatch.threads,
5611 quiet: dispatch.quiet,
5612 allow_remote_extends: cli.allow_remote_extends,
5613 changed_since: cli.changed_since.as_deref(),
5614 production: cli.production,
5615 production_dead_code: Some(inputs.production.dead_code),
5616 production_health: Some(inputs.production.health),
5617 production_dupes: Some(inputs.production.dupes),
5618 workspace: cli.workspace.as_deref(),
5619 changed_workspaces: cli.changed_workspaces.as_deref(),
5620 explain: cli.explain,
5621 explain_skipped: cli.explain_skipped,
5622 performance: cli.performance,
5623 group_by: cli.group_by,
5624 dead_code_baseline: inputs.dead_code_baseline.as_deref(),
5625 health_baseline: inputs.health_baseline.as_deref(),
5626 dupes_baseline: inputs.dupes_baseline.as_deref(),
5627 health_baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
5628 max_crap: None,
5629 coverage: None,
5630 coverage_root: None,
5631 gate: inputs.audit_cfg.gate,
5632 include_entry_exports: cli.include_entry_exports,
5633 css: false,
5635 css_deep: false,
5636 runtime_coverage: None,
5637 min_invocations_hot: 0,
5638 brief: true,
5639 max_decisions,
5640 walkthrough_guide: false,
5641 walkthrough: false,
5642 mark_viewed: &[],
5643 show_cleared: false,
5644 walkthrough_file: None,
5645 show_deprioritized: false,
5646 }
5647}
5648
5649struct HealthDispatchArgs<'a> {
5650 max_cyclomatic: Option<u16>,
5651 max_cognitive: Option<u16>,
5652 max_crap: Option<f64>,
5653 top: Option<usize>,
5654 sort: health::SortBy,
5655 complexity: bool,
5656 complexity_breakdown: bool,
5657 file_scores: bool,
5658 coverage_gaps: bool,
5659 hotspots: bool,
5660 ownership: bool,
5661 ownership_emails: Option<fallow_config::EmailMode>,
5662 targets: bool,
5663 type_coupling: bool,
5664 css: bool,
5665 effort: Option<EffortFilter>,
5666 score: bool,
5667 min_score: Option<f64>,
5668 min_severity: Option<fallow_output::FindingSeverity>,
5669 report_only: bool,
5670 since: Option<&'a str>,
5671 min_commits: Option<u32>,
5672 save_snapshot: Option<&'a Option<String>>,
5673 trend: bool,
5674 coverage: Option<&'a std::path::Path>,
5675 coverage_root: Option<&'a std::path::Path>,
5676 runtime_coverage: Option<&'a std::path::Path>,
5677 min_invocations_hot: u64,
5678 min_observation_volume: Option<u32>,
5679 low_traffic_threshold: Option<f64>,
5680}
5681
5682type ResolvedHealthCoverageInputs = fallow_api::CoverageInputs;
5683
5684fn resolve_coverage_inputs(
5695 cli_coverage: Option<&std::path::Path>,
5696 cli_coverage_root: Option<&std::path::Path>,
5697 output: fallow_config::OutputFormat,
5698 config_health: impl FnOnce() -> Result<fallow_config::HealthConfig, ExitCode>,
5699) -> Result<ResolvedHealthCoverageInputs, ExitCode> {
5700 let explicit = fallow_api::CoverageInputs {
5701 coverage: cli_coverage.map(std::path::Path::to_path_buf),
5702 coverage_root: cli_coverage_root.map(std::path::Path::to_path_buf),
5703 };
5704 let env = fallow_api::CoverageInputs {
5705 coverage: path_from_env("FALLOW_COVERAGE"),
5706 coverage_root: path_from_env("FALLOW_COVERAGE_ROOT"),
5707 };
5708 let config_health = if fallow_api::CoverageInputs::needs_config_layer(&explicit, &env) {
5709 Some(config_health()?)
5710 } else {
5711 None
5712 };
5713
5714 fallow_api::resolve_coverage_inputs(explicit, env, config_health.as_ref())
5715 .map_err(|err| emit_error(&err.to_string(), 2, output))
5716}
5717
5718fn resolve_health_coverage_inputs(
5721 dispatch: &DispatchContext<'_>,
5722 cli_coverage: Option<&std::path::Path>,
5723 cli_coverage_root: Option<&std::path::Path>,
5724) -> Result<ResolvedHealthCoverageInputs, ExitCode> {
5725 resolve_coverage_inputs(cli_coverage, cli_coverage_root, dispatch.output, || {
5726 Ok(load_config(
5727 dispatch.root,
5728 &dispatch.cli.config,
5729 LoadConfigArgs {
5730 output: dispatch.output,
5731 no_cache: dispatch.cli.no_cache,
5732 threads: dispatch.threads,
5733 production: dispatch.cli.production,
5734 quiet: dispatch.quiet,
5735 allow_remote_extends: dispatch.cli.allow_remote_extends,
5736 },
5737 )?
5738 .health)
5739 })
5740}
5741
5742fn path_from_env(name: &str) -> Option<PathBuf> {
5743 std::env::var_os(name)
5744 .filter(|value| !value.is_empty())
5745 .map(PathBuf::from)
5746}
5747
5748fn validate_health_report_only_gate(
5749 report_only: bool,
5750 min_score: Option<f64>,
5751 min_severity: Option<fallow_output::FindingSeverity>,
5752 output: fallow_config::OutputFormat,
5753) -> Result<(), ExitCode> {
5754 if report_only && (min_score.is_some() || min_severity.is_some()) {
5755 return Err(emit_error(
5756 "--report-only cannot be combined with --min-score or --min-severity. \
5757 --report-only always exits 0; drop it to gate on score/severity, or \
5758 drop the gate flags to stay advisory.",
5759 2,
5760 output,
5761 ));
5762 }
5763
5764 Ok(())
5765}
5766
5767fn resolve_runtime_coverage_options(
5768 runtime_coverage: Option<&std::path::Path>,
5769 min_invocations_hot: u64,
5770 min_observation_volume: Option<u32>,
5771 low_traffic_threshold: Option<f64>,
5772 output: fallow_config::OutputFormat,
5773) -> Result<Option<fallow_engine::health::RuntimeCoverageOptions>, ExitCode> {
5774 let Some(path) = runtime_coverage else {
5775 return Ok(None);
5776 };
5777
5778 health::coverage::prepare_options(
5779 path,
5780 min_invocations_hot,
5781 min_observation_volume,
5782 low_traffic_threshold,
5783 output,
5784 )
5785 .map(Some)
5786}
5787
5788fn dispatch_health(dispatch: &DispatchContext<'_>, args: &HealthDispatchArgs<'_>) -> ExitCode {
5789 let cli = dispatch.cli;
5790 let root = dispatch.root;
5791 let (output, _quiet, _fail_on_issues) =
5792 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
5793 if let Err(code) = validate_health_report_only_gate(
5794 args.report_only,
5795 args.min_score,
5796 args.min_severity,
5797 output,
5798 ) {
5799 return code;
5800 }
5801 let runtime_coverage = match resolve_runtime_coverage_options(
5802 args.runtime_coverage,
5803 args.min_invocations_hot,
5804 args.min_observation_volume,
5805 args.low_traffic_threshold,
5806 output,
5807 ) {
5808 Ok(options) => options,
5809 Err(code) => return code,
5810 };
5811 let production = match resolve_production_modes(cli, root, output, false, false, false) {
5812 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::Health),
5813 Err(code) => return code,
5814 };
5815 let coverage_inputs =
5816 match resolve_health_coverage_inputs(dispatch, args.coverage, args.coverage_root) {
5817 Ok(inputs) => inputs,
5818 Err(code) => return code,
5819 };
5820 let run = derive_health_dispatch_run(args, output, &coverage_inputs, runtime_coverage);
5821 run_health_dispatch(dispatch, args, ResolvedHealthDispatch { run, production })
5822}
5823
5824fn derive_health_dispatch_run<'a>(
5825 args: &'a HealthDispatchArgs<'a>,
5826 output: fallow_config::OutputFormat,
5827 coverage_inputs: &'a ResolvedHealthCoverageInputs,
5828 runtime_coverage: Option<fallow_engine::health::RuntimeCoverageOptions>,
5829) -> fallow_engine::health::HealthRunOptions<'a> {
5830 let mut run = fallow_engine::health::derive_health_run_options(
5831 fallow_engine::health::HealthRunOptionsInput {
5832 output,
5833 thresholds: health_threshold_overrides(args),
5834 top: args.top,
5835 sort: args.sort.clone().into(),
5836 complexity: args.complexity,
5837 file_scores: args.file_scores,
5838 coverage_gaps: args.coverage_gaps,
5839 hotspots: args.hotspots,
5840 ownership: args.ownership,
5841 ownership_emails: args.ownership_emails,
5842 targets: args.targets,
5843 css: args.css,
5844 effort: args.effort.map(EffortFilter::to_estimate),
5845 score: args.score,
5846 gates: health_gate_options(args),
5847 snapshot_requested: args.save_snapshot.is_some(),
5848 trend: args.trend,
5849 since: args.since,
5850 min_commits: args.min_commits,
5851 coverage_inputs: health_coverage_inputs(coverage_inputs),
5852 runtime_coverage,
5853 },
5854 );
5855 if args.type_coupling && !run.sections.any_section {
5856 run.sections = fallow_engine::health::DerivedHealthSections {
5857 any_section: true,
5858 complexity: false,
5859 file_scores: false,
5860 coverage_gaps: false,
5861 hotspots: false,
5862 targets: false,
5863 css: false,
5864 score: false,
5865 force_full: false,
5866 score_only_output: false,
5867 };
5868 }
5869 run
5870}
5871
5872fn health_threshold_overrides(
5873 args: &HealthDispatchArgs<'_>,
5874) -> fallow_engine::health::HealthThresholdOverrides {
5875 fallow_engine::health::HealthThresholdOverrides {
5876 max_cyclomatic: args.max_cyclomatic,
5877 max_cognitive: args.max_cognitive,
5878 max_crap: args.max_crap,
5879 }
5880}
5881
5882fn health_gate_options(args: &HealthDispatchArgs<'_>) -> fallow_engine::health::HealthGateOptions {
5883 fallow_engine::health::HealthGateOptions {
5884 min_score: args.min_score,
5885 min_severity: args.min_severity,
5886 report_only: args.report_only,
5887 }
5888}
5889
5890fn health_coverage_inputs(
5891 coverage_inputs: &ResolvedHealthCoverageInputs,
5892) -> fallow_engine::health::HealthCoverageInputs<'_> {
5893 fallow_engine::health::HealthCoverageInputs {
5894 coverage: coverage_inputs.coverage.as_deref(),
5895 coverage_root: coverage_inputs.coverage_root.as_deref(),
5896 coverage_relocated: false,
5897 }
5898}
5899
5900struct ResolvedHealthDispatch<'a> {
5904 run: fallow_engine::health::HealthRunOptions<'a>,
5905 production: bool,
5906}
5907
5908fn run_health_dispatch(
5911 dispatch: &DispatchContext<'_>,
5912 args: &HealthDispatchArgs<'_>,
5913 resolved: ResolvedHealthDispatch<'_>,
5914) -> ExitCode {
5915 let cli = dispatch.cli;
5916 let (output, quiet, _fail_on_issues) =
5917 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
5918 let run = resolved.run;
5919 let sections = run.sections;
5920 let production = resolved.production;
5921 health::run_health(
5922 &HealthOptions {
5923 root: dispatch.root,
5924 config_path: &cli.config,
5925 output,
5926 no_cache: cli.no_cache,
5927 threads: dispatch.threads,
5928 quiet,
5929 thresholds: run.thresholds,
5930 top: run.top,
5931 sort: run.sort,
5932 production,
5933 production_override: Some(production),
5934 allow_remote_extends: cli.allow_remote_extends,
5935 changed_since: cli.changed_since.as_deref(),
5936 diff_index: None,
5937 use_shared_diff_index: true,
5938 workspace: cli.workspace.as_deref(),
5939 changed_workspaces: cli.changed_workspaces.as_deref(),
5940 baseline: cli.baseline.as_deref(),
5941 save_baseline: cli.save_baseline.as_deref(),
5942 baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
5943 baseline_mode_explicit: cli.baseline_mode.is_some(),
5944 complexity: sections.complexity,
5945 file_scores: sections.file_scores,
5946 coverage_gaps: sections.coverage_gaps,
5947 config_activates_coverage_gaps: !sections.any_section,
5948 hotspots: sections.hotspots,
5949 ownership: run.ownership,
5950 ownership_emails: run.ownership_emails,
5951 targets: sections.targets,
5952 css: sections.css,
5953 css_deep: false,
5954 force_full: sections.force_full,
5955 score_only_output: sections.score_only_output,
5956 enforce_coverage_gap_gate: true,
5957 effort: run.effort,
5958 score: sections.score,
5959 gates: run.gates,
5960 since: run.since,
5961 min_commits: run.min_commits,
5962 explain: cli.explain,
5963 summary: cli.summary,
5964 save_snapshot: args
5965 .save_snapshot
5966 .map(|opt| PathBuf::from(opt.as_deref().unwrap_or_default())),
5967 trend: args.trend,
5968 coverage_inputs: run.coverage_inputs,
5969 performance: cli.performance,
5970 runtime_coverage: run.runtime_coverage,
5971 churn_file: cli.churn_file.as_deref(),
5972 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
5973 complexity_breakdown: args.complexity_breakdown,
5974 group_by: cli.group_by.map(Into::into),
5975 },
5976 dispatch.json_style,
5977 &health::TypeAwareHealthOptions {
5978 enabled: cli.type_aware_override(),
5979 requested: args.type_coupling,
5980 unfiltered: health_type_coupling_is_default_section(args),
5981 projects: &cli.type_aware_project,
5982 require: cli.type_aware_require.map(Into::into),
5983 },
5984 )
5985}
5986
5987fn health_type_coupling_is_default_section(args: &HealthDispatchArgs<'_>) -> bool {
5988 !args.complexity
5989 && !args.file_scores
5990 && !args.coverage_gaps
5991 && !args.hotspots
5992 && !args.ownership
5993 && !args.targets
5994 && !args.css
5995 && !args.score
5996 && args.min_score.is_none()
5997 && args.min_severity.is_none()
5998 && args.runtime_coverage.is_none()
5999}
6000
6001#[cfg(test)]
6002mod tests {
6003 use super::*;
6004
6005 #[test]
6009 fn cli_definition_has_no_flag_collisions() {
6010 use clap::CommandFactory;
6011 Cli::command().debug_assert();
6012 }
6013
6014 #[test]
6015 fn impact_statusline_subcommand_parses() {
6016 use clap::Parser;
6017
6018 let cli = Cli::try_parse_from(["fallow", "impact", "statusline"]).expect("argv parses");
6019 assert!(matches!(
6020 cli.command,
6021 Some(Command::Impact {
6022 subcommand: Some(ImpactCli::Statusline),
6023 ..
6024 })
6025 ));
6026 }
6027
6028 #[test]
6029 fn impact_statusline_bypasses_command_epilogue() {
6030 use clap::Parser;
6031
6032 let statusline =
6033 Cli::try_parse_from(["fallow", "impact", "statusline"]).expect("argv parses");
6034 assert!(is_impact_statusline(&statusline));
6035
6036 let status = Cli::try_parse_from(["fallow", "impact", "status"]).expect("argv parses");
6037 assert!(!is_impact_statusline(&status));
6038
6039 let all_statusline =
6040 Cli::try_parse_from(["fallow", "impact", "--all", "statusline"]).expect("argv parses");
6041 assert!(!is_impact_statusline(&all_statusline));
6042 }
6043
6044 #[test]
6045 fn regression_baseline_help_explains_the_default_destination() {
6046 use clap::CommandFactory;
6047 let help = Cli::command().render_long_help().to_string();
6048
6049 assert!(help.contains("Omit PATH to update regression.baseline"));
6050 assert!(help.contains("discovered fallow config"));
6051 assert!(help.contains("create .fallowrc.json when none exists"));
6052 }
6053
6054 #[test]
6058 fn after_help_lists_every_task_matrix_command() {
6059 for row in crate::task_matrix::TASK_MATRIX {
6060 assert!(
6061 TOP_LEVEL_AFTER_LONG_HELP.contains(row.command),
6062 "root --help cheat sheet is missing task-matrix command '{}'; \
6063 update the top_level_task_cheat_sheet! fragment to match TASK_MATRIX",
6064 row.command
6065 );
6066 }
6067 }
6068
6069 #[test]
6076 fn after_help_lists_every_visible_subcommand() {
6077 use clap::CommandFactory;
6078
6079 for sub in Cli::command().get_subcommands() {
6080 if sub.is_hide_set() {
6081 continue;
6082 }
6083 let name = sub.get_name();
6084 let listed = TOP_LEVEL_AFTER_LONG_HELP
6085 .lines()
6086 .any(|line| line.split_whitespace().next() == Some(name));
6087 assert!(
6088 listed,
6089 "root --help command list is missing subcommand '{name}'; \
6090 add it to a top_level_*_command_groups! section"
6091 );
6092 }
6093 }
6094
6095 #[test]
6099 fn short_help_stays_scannable_with_cheat_sheet_and_pointer() {
6100 use clap::CommandFactory;
6101
6102 let help = Cli::command().render_help().to_string();
6103 let lines = help.lines().count();
6104 assert!(
6105 lines < 90,
6106 "root -h grew to {lines} lines; keep the short surface under 90 \
6107 (curate hide_short_help and the short after-help instead)"
6108 );
6109 assert!(help.contains("When the agent is about to..."));
6110 assert!(help.contains("Run fallow --help for the complete command list."));
6111 }
6112
6113 #[test]
6117 fn high_value_commands_route_to_distinct_workflows() {
6118 use clap::Parser;
6119 use fallow_config::OutputFormat;
6120
6121 let distinct = [
6122 (vec!["fallow", "impact"], telemetry::Workflow::Impact),
6123 (vec!["fallow", "security"], telemetry::Workflow::Security),
6124 (vec!["fallow", "fix"], telemetry::Workflow::Fix),
6125 (
6126 vec!["fallow", "explain", "unused-exports"],
6127 telemetry::Workflow::Explain,
6128 ),
6129 (
6130 vec!["fallow", "watch"],
6131 telemetry::Workflow::CodeQualityReview,
6132 ),
6133 (
6134 vec!["fallow", "list"],
6135 telemetry::Workflow::ProjectInventory,
6136 ),
6137 (
6138 vec!["fallow", "workspaces"],
6139 telemetry::Workflow::ProjectInventory,
6140 ),
6141 (
6142 vec!["fallow", "schema"],
6143 telemetry::Workflow::ProjectInventory,
6144 ),
6145 (vec!["fallow", "init"], telemetry::Workflow::Setup),
6146 (
6147 vec!["fallow", "hooks", "install", "--target", "git"],
6148 telemetry::Workflow::Setup,
6149 ),
6150 (vec!["fallow", "config-schema"], telemetry::Workflow::Setup),
6151 (vec!["fallow", "plugin-schema"], telemetry::Workflow::Setup),
6152 (
6153 vec!["fallow", "rule-pack-schema"],
6154 telemetry::Workflow::Setup,
6155 ),
6156 (vec!["fallow", "config"], telemetry::Workflow::Setup),
6157 (
6158 vec!["fallow", "ci-template", "gitlab"],
6159 telemetry::Workflow::Setup,
6160 ),
6161 (vec!["fallow", "migrate"], telemetry::Workflow::Setup),
6162 (
6163 vec!["fallow", "telemetry", "status"],
6164 telemetry::Workflow::Setup,
6165 ),
6166 (vec!["fallow", "setup-hooks"], telemetry::Workflow::Setup),
6167 (
6168 vec!["fallow", "audit-cache", "remove", "--root", "."],
6169 telemetry::Workflow::Setup,
6170 ),
6171 (
6172 vec!["fallow", "license", "status"],
6173 telemetry::Workflow::License,
6174 ),
6175 ];
6176 for (argv, expected) in distinct {
6177 let cli = Cli::try_parse_from(&argv).expect("argv parses");
6178 assert_eq!(
6179 telemetry_workflow_for_command(cli.command.as_ref(), OutputFormat::Json),
6180 expected,
6181 "{argv:?} should map to {expected:?}"
6182 );
6183 }
6184 }
6185
6186 #[test]
6191 fn version_flag_accepts_lower_v_upper_v_and_long() {
6192 use clap::CommandFactory;
6193 for argv in [["fallow", "-v"], ["fallow", "-V"], ["fallow", "--version"]] {
6194 let err = Cli::command()
6195 .try_get_matches_from(argv)
6196 .expect_err("version flag should short-circuit parsing");
6197 assert_eq!(
6198 err.kind(),
6199 clap::error::ErrorKind::DisplayVersion,
6200 "{argv:?} should trigger the Version action"
6201 );
6202 }
6203 }
6204
6205 #[test]
6210 fn cli_help_text_contains_no_implementation_status_wording() {
6211 use clap::CommandFactory;
6212 let mut root = Cli::command();
6213 let mut violations: Vec<(String, String)> = Vec::new();
6214 visit_help(&mut root, "fallow", &mut violations);
6215 assert!(
6216 violations.is_empty(),
6217 "found implementation-status wording in --help output:\n{}",
6218 violations
6219 .iter()
6220 .map(|(cmd, line)| format!(" {cmd}: {line}"))
6221 .collect::<Vec<_>>()
6222 .join("\n")
6223 );
6224 }
6225
6226 #[test]
6227 fn dependency_override_help_is_package_manager_neutral() {
6228 use clap::CommandFactory;
6229 let help = Cli::command()
6230 .find_subcommand_mut("dead-code")
6231 .expect("dead-code command")
6232 .render_long_help()
6233 .to_string();
6234
6235 assert!(help.contains("Only report unused package-manager dependency overrides"));
6236 assert!(help.contains("Only report misconfigured package-manager dependency overrides"));
6237 assert!(!help.contains("unused pnpm dependency overrides"));
6238 assert!(!help.contains("misconfigured pnpm dependency overrides"));
6239 }
6240
6241 #[test]
6242 fn top_level_help_groups_commands_by_workflow() {
6243 use clap::CommandFactory;
6244 let help = Cli::command().render_long_help().to_string();
6245 let expected_order = [
6246 "Analysis:",
6247 " dead-code",
6248 " dupes",
6249 " health",
6250 " flags",
6251 " security",
6252 " audit",
6253 "Workflow:",
6254 " watch",
6255 " fix",
6256 "Project inspection:",
6257 " list",
6258 " workspaces",
6259 " explain",
6260 " impact",
6261 " viz",
6262 "Setup and configuration:",
6263 " init",
6264 " recommend",
6265 " migrate",
6266 " config",
6267 " config-schema",
6268 " plugin-schema",
6269 " plugin-check",
6270 " rule-pack-schema",
6271 "Automation and CI:",
6272 " ci",
6273 " ci-template",
6274 " hooks",
6275 " setup-hooks",
6276 "Runtime coverage:",
6277 " coverage",
6278 " license",
6279 "Reference:",
6280 " schema",
6281 " help",
6282 "Options:",
6283 ];
6284 let mut cursor = 0;
6285 for needle in expected_order {
6286 let Some(offset) = help[cursor..].find(needle) else {
6287 panic!("top-level help missing `{needle}` after byte {cursor}:\n{help}");
6288 };
6289 cursor += offset + needle.len();
6290 }
6291 }
6292
6293 #[test]
6294 fn security_help_hides_globals_rejected_by_security_validator() {
6295 let help = render_security_help(SecurityHelpTarget::Parent);
6296
6297 for long in SECURITY_UNSUPPORTED_GLOBAL_LONGS {
6298 assert!(
6299 !help_contains_long_flag(&help, long),
6300 "security help must hide unsupported --{long}:\n{help}"
6301 );
6302 }
6303
6304 for long in [
6305 "root",
6306 "config",
6307 "format",
6308 "quiet",
6309 "no-cache",
6310 "threads",
6311 "changed-since",
6312 "diff-file",
6313 "diff-stdin",
6314 "workspace",
6315 "changed-workspaces",
6316 "ci",
6317 "fail-on-issues",
6318 "sarif-file",
6319 "summary",
6320 "output-file",
6321 "max-file-size",
6322 "explain",
6323 "surface",
6324 ] {
6325 assert!(
6326 help_contains_long_flag(&help, long),
6327 "security help must keep supported --{long}:\n{help}"
6328 );
6329 }
6330 }
6331
6332 #[test]
6333 fn security_help_detection_covers_subcommand_and_help_alias_forms() {
6334 assert_eq!(
6335 security_help_target(["security", "--help"]),
6336 Some(SecurityHelpTarget::Parent)
6337 );
6338 assert_eq!(
6339 security_help_target(["security", "-h"]),
6340 Some(SecurityHelpTarget::Parent)
6341 );
6342 assert_eq!(
6343 security_help_target(["--format", "json", "security", "--help"]),
6344 Some(SecurityHelpTarget::Parent)
6345 );
6346 assert_eq!(
6347 security_help_target(["help", "security"]),
6348 Some(SecurityHelpTarget::Parent)
6349 );
6350 assert_eq!(
6351 security_help_target(["security", "survivors", "--help"]),
6352 Some(SecurityHelpTarget::Survivors)
6353 );
6354 assert_eq!(
6355 security_help_target(["security", "survivors", "-h"]),
6356 Some(SecurityHelpTarget::Survivors)
6357 );
6358 assert_eq!(
6359 security_help_target(["help", "security", "survivors"]),
6360 Some(SecurityHelpTarget::Survivors)
6361 );
6362 assert_eq!(
6363 security_help_target(["security", "blind-spots", "--help"]),
6364 Some(SecurityHelpTarget::BlindSpots)
6365 );
6366 assert_eq!(
6367 security_help_target(["help", "security", "blind-spots"]),
6368 Some(SecurityHelpTarget::BlindSpots)
6369 );
6370 assert_eq!(security_help_target(["health", "--help"]), None);
6371 assert_eq!(security_help_target(["help", "health"]), None);
6372 }
6373
6374 #[test]
6375 fn security_unsupported_global_validator_matches_hidden_help_contract() {
6376 for (argv, expected) in [
6377 (vec!["fallow", "security", "--performance"], "--performance"),
6378 (
6379 vec!["fallow", "security", "--baseline", "base.json"],
6380 "--baseline",
6381 ),
6382 (
6383 vec!["fallow", "security", "--dupes-mode", "weak"],
6384 "--dupes-mode",
6385 ),
6386 ] {
6387 let cli = Cli::try_parse_from(argv).expect("security global parses before validation");
6388 assert_eq!(unsupported_security_global(&cli), Some(expected));
6389 }
6390
6391 let explain = Cli::try_parse_from(["fallow", "security", "--explain"])
6392 .expect("security --explain parses");
6393 assert_eq!(unsupported_security_global(&explain), None);
6394 }
6395
6396 #[test]
6397 fn programmatic_common_options_track_analysis_affecting_cli_globals() {
6398 use clap::CommandFactory;
6399
6400 let cli_flags: std::collections::BTreeSet<String> = Cli::command()
6401 .get_arguments()
6402 .filter(|arg| arg.is_global_set())
6403 .filter_map(|arg| arg.get_long().map(str::to_owned))
6404 .filter(|name| {
6405 matches!(
6406 name.as_str(),
6407 "root"
6408 | "config"
6409 | "allow-remote-extends"
6410 | "no-cache"
6411 | "threads"
6412 | "changed-since"
6413 | "diff-file"
6414 | "production"
6415 | "workspace"
6416 | "changed-workspaces"
6417 | "explain"
6418 )
6419 })
6420 .collect();
6421 let programmatic_flags: std::collections::BTreeSet<String> =
6422 fallow_api::COMMON_ANALYSIS_OPTION_FLAGS
6423 .iter()
6424 .map(|flag| (*flag).to_owned())
6425 .collect();
6426
6427 assert_eq!(programmatic_flags, cli_flags);
6428 }
6429
6430 #[test]
6431 fn dead_code_registry_filter_flags_are_exposed_by_clap() {
6432 use clap::CommandFactory;
6433
6434 let cli = Cli::command();
6435 let dead_code = cli
6436 .get_subcommands()
6437 .find(|command| command.get_name() == "dead-code")
6438 .expect("dead-code subcommand is registered");
6439 let cli_flags: std::collections::BTreeSet<String> = dead_code
6440 .get_arguments()
6441 .filter_map(|arg| arg.get_long().map(|long| format!("--{long}")))
6442 .collect();
6443
6444 for flag in fallow_types::issue_meta::DEAD_CODE_FILTER_FLAGS.iter() {
6445 assert!(
6446 cli_flags.contains(*flag),
6447 "registry filter flag {flag} is missing from dead-code clap args"
6448 );
6449 }
6450 }
6451
6452 fn help_contains_long_flag(help: &str, long: &str) -> bool {
6453 let flag = format!("--{long}");
6454 help.split(|c: char| c.is_whitespace() || c == ',' || c == '[' || c == ']')
6455 .any(|token| token == flag)
6456 }
6457
6458 fn visit_help(cmd: &mut clap::Command, path: &str, violations: &mut Vec<(String, String)>) {
6459 let help = cmd.render_long_help().to_string();
6460 for line in scan_forbidden(&help) {
6461 violations.push((path.to_owned(), line));
6462 }
6463 let names: Vec<String> = cmd
6464 .get_subcommands()
6465 .map(|sub| sub.get_name().to_owned())
6466 .collect();
6467 for name in names {
6468 if name == "help" {
6469 continue;
6470 }
6471 if let Some(sub) = cmd.find_subcommand_mut(&name) {
6472 let sub_path = format!("{path} {name}");
6473 visit_help(sub, &sub_path, violations);
6474 }
6475 }
6476 }
6477
6478 fn scan_forbidden(s: &str) -> Vec<String> {
6479 let lower = s.to_ascii_lowercase();
6480 let mut out = Vec::new();
6481 for word in ["stub", "placeholder"] {
6482 if let Some(idx) = find_whole_word(&lower, word) {
6483 out.push(extract_line(s, idx));
6484 }
6485 }
6486 if let Some(idx) = lower.find("not yet") {
6487 out.push(extract_line(s, idx));
6488 }
6489 out
6490 }
6491
6492 fn find_whole_word(haystack: &str, word: &str) -> Option<usize> {
6493 let bytes = haystack.as_bytes();
6494 let mut start = 0;
6495 while let Some(rel) = haystack[start..].find(word) {
6496 let abs = start + rel;
6497 let before_ok = abs == 0 || !bytes[abs - 1].is_ascii_alphanumeric();
6498 let after_idx = abs + word.len();
6499 let after_ok = after_idx >= bytes.len() || !bytes[after_idx].is_ascii_alphanumeric();
6500 if before_ok && after_ok {
6501 return Some(abs);
6502 }
6503 start = abs + word.len();
6504 }
6505 None
6506 }
6507
6508 fn extract_line(s: &str, byte_idx: usize) -> String {
6509 let line_start = s[..byte_idx].rfind('\n').map_or(0, |i| i + 1);
6510 let line_end = s[byte_idx..].find('\n').map_or(s.len(), |i| byte_idx + i);
6511 s[line_start..line_end].trim().to_owned()
6512 }
6513
6514 #[test]
6515 fn emit_error_returns_given_exit_code() {
6516 let code = emit_error("test error", 2, fallow_config::OutputFormat::Human);
6517 assert_eq!(code, ExitCode::from(2));
6518 }
6519
6520 fn telemetry_run_for_mode(mode: telemetry::AnalysisMode) -> TelemetryRun {
6521 TelemetryRun {
6522 workflow: telemetry::Workflow::Health,
6523 output: fallow_config::OutputFormat::Json,
6524 quiet: true,
6525 start: std::time::Instant::now(),
6526 context: telemetry::WorkflowContext {
6527 run_scope: telemetry::RunScope::FullProject,
6528 config_shape: telemetry::ConfigShape::Default,
6529 output_destination: telemetry::OutputDestination::Stdout,
6530 analysis_mode: mode,
6531 },
6532 }
6533 }
6534
6535 #[test]
6536 fn fallback_failure_reason_skips_success_and_findings() {
6537 let run = telemetry_run_for_mode(telemetry::AnalysisMode::Static);
6538
6539 assert_eq!(fallback_failure_reason_for(&run, ExitCode::SUCCESS), None);
6540 assert_eq!(fallback_failure_reason_for(&run, ExitCode::from(1)), None);
6541 }
6542
6543 #[test]
6544 fn fallback_failure_reason_classifies_network_auth_and_analysis() {
6545 let static_run = telemetry_run_for_mode(telemetry::AnalysisMode::Static);
6546 let cloud_run = telemetry_run_for_mode(telemetry::AnalysisMode::ProductionCoverage);
6547
6548 assert_eq!(
6549 fallback_failure_reason_for(&static_run, ExitCode::from(api::NETWORK_EXIT_CODE)),
6550 Some(telemetry::FailureReason::Network),
6551 );
6552 assert_eq!(
6553 fallback_failure_reason_for(&static_run, ExitCode::from(12)),
6554 Some(telemetry::FailureReason::Auth),
6555 );
6556 assert_eq!(
6557 fallback_failure_reason_for(&cloud_run, ExitCode::from(3)),
6558 Some(telemetry::FailureReason::Auth),
6559 );
6560 assert_eq!(
6561 fallback_failure_reason_for(&static_run, ExitCode::from(2)),
6562 Some(telemetry::FailureReason::Analysis),
6563 );
6564 }
6565
6566 #[test]
6567 fn bare_coverage_flags_parse_without_subcommand() {
6568 let cli = Cli::try_parse_from([
6569 "fallow",
6570 "--coverage",
6571 "coverage/coverage-final.json",
6572 "--coverage-root",
6573 "/ci/workspace",
6574 ])
6575 .expect("bare combined coverage flags should parse");
6576 assert!(cli.command.is_none());
6577 assert_eq!(
6578 cli.coverage.as_deref(),
6579 Some(std::path::Path::new("coverage/coverage-final.json"))
6580 );
6581 assert_eq!(
6582 cli.coverage_root.as_deref(),
6583 Some(std::path::Path::new("/ci/workspace"))
6584 );
6585 }
6586
6587 #[test]
6588 fn bare_coverage_before_subcommand_is_detectable() {
6589 let cli = Cli::try_parse_from([
6590 "fallow",
6591 "--coverage",
6592 "coverage/coverage-final.json",
6593 "dead-code",
6594 ])
6595 .expect("clap should parse pre-subcommand bare coverage for custom rejection");
6596 assert!(cli.command.is_some());
6597 assert!(cli_has_bare_coverage_input(&cli));
6598 let message = bare_coverage_subcommand_error_message();
6599 assert!(message.contains("bare combined-mode flags"));
6600 assert!(message.contains("fallow health --coverage <coverage-final.json>"));
6601 }
6602
6603 #[test]
6604 fn subcommand_coverage_flag_keeps_regular_clap_error() {
6605 let Err(err) = Cli::try_parse_from(["fallow", "dead-code", "--coverage"]) else {
6606 panic!("dead-code --coverage should fail to parse");
6607 };
6608 assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
6609 }
6610
6611 #[test]
6612 fn type_aware_flags_parse_for_semantic_analysis() {
6613 let cli = Cli::try_parse_from([
6614 "fallow",
6615 "dead-code",
6616 "--unused-class-members",
6617 "--type-aware",
6618 "--type-aware-project",
6619 "tsconfig.json",
6620 "--type-aware-project",
6621 "packages/web/tsconfig.json",
6622 ])
6623 .expect("type-aware flag should parse");
6624 assert!(cli.type_aware);
6625 assert_eq!(
6626 cli.type_aware_project,
6627 [
6628 PathBuf::from("tsconfig.json"),
6629 PathBuf::from("packages/web/tsconfig.json")
6630 ]
6631 );
6632 let Some(Command::Check {
6633 unused_class_members,
6634 ..
6635 }) = cli.command
6636 else {
6637 panic!("dead-code should parse as the check command");
6638 };
6639 assert!(unused_class_members);
6640 }
6641
6642 #[test]
6643 fn no_type_aware_conflicts_with_type_aware() {
6644 let Err(err) = Cli::try_parse_from(["fallow", "audit", "--type-aware", "--no-type-aware"])
6645 else {
6646 panic!("--no-type-aware must conflict with --type-aware");
6647 };
6648 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
6649 }
6650
6651 #[test]
6652 fn no_type_aware_forces_semantic_analysis_off() {
6653 let cli = Cli::try_parse_from(["fallow", "audit", "--no-type-aware"])
6654 .expect("--no-type-aware should parse on audit");
6655 assert_eq!(cli.type_aware_override(), Some(false));
6656
6657 let cli = Cli::try_parse_from(["fallow", "dead-code", "--type-aware"])
6658 .expect("--type-aware should parse");
6659 assert_eq!(cli.type_aware_override(), Some(true));
6660
6661 let cli = Cli::try_parse_from(["fallow", "dead-code"]).expect("bare command should parse");
6662 assert_eq!(cli.type_aware_override(), None);
6663 }
6664
6665 #[test]
6666 fn type_aware_status_output_hides_host_paths() {
6667 let root = Path::new("/private/work/project");
6668 let output = type_aware_status_output(
6669 root,
6670 fallow_api::TypeAwareStatus {
6671 available: false,
6672 discovery_source: Some("environment-override"),
6673 companion_path: Some(PathBuf::from("/private/tools/fallow-type-aware")),
6674 package_version: None,
6675 protocol_version: 7,
6676 backend_family: None,
6677 backend_version: None,
6678 remediation: Some(
6679 "failed to launch /private/tools/fallow-type-aware from /private/work/project"
6680 .to_string(),
6681 ),
6682 },
6683 );
6684
6685 assert_eq!(
6686 output.schema_version.0,
6687 fallow_output::TYPE_AWARE_STATUS_SCHEMA_VERSION
6688 );
6689 assert_eq!(output.companion_path.as_deref(), Some("fallow-type-aware"));
6690 let remediation = output.remediation.expect("remediation");
6691 assert!(!remediation.contains("/private/"));
6692 assert!(remediation.contains("fallow-type-aware"));
6693 }
6694
6695 #[test]
6696 fn format_parsing_covers_all_variants() {
6697 assert!(matches!(parse_format_arg("json"), Some(Format::Json)));
6698 assert!(matches!(parse_format_arg("JSON"), Some(Format::Json)));
6699 assert!(matches!(parse_format_arg("human"), Some(Format::Human)));
6700 assert!(matches!(parse_format_arg("sarif"), Some(Format::Sarif)));
6701 assert!(matches!(parse_format_arg("compact"), Some(Format::Compact)));
6702 assert!(matches!(
6703 parse_format_arg("markdown"),
6704 Some(Format::Markdown)
6705 ));
6706 assert!(matches!(parse_format_arg("md"), Some(Format::Markdown)));
6707 assert!(matches!(
6708 parse_format_arg("codeclimate"),
6709 Some(Format::CodeClimate)
6710 ));
6711 assert!(matches!(
6712 parse_format_arg("gitlab-codequality"),
6713 Some(Format::CodeClimate)
6714 ));
6715 assert!(matches!(
6716 parse_format_arg("gitlab-code-quality"),
6717 Some(Format::CodeClimate)
6718 ));
6719 assert!(matches!(
6720 parse_format_arg("pr-comment-github"),
6721 Some(Format::PrCommentGithub)
6722 ));
6723 assert!(matches!(
6724 parse_format_arg("pr-comment-gitlab"),
6725 Some(Format::PrCommentGitlab)
6726 ));
6727 assert!(matches!(
6728 parse_format_arg("review-github"),
6729 Some(Format::ReviewGithub)
6730 ));
6731 assert!(matches!(
6732 parse_format_arg("review-gitlab"),
6733 Some(Format::ReviewGitlab)
6734 ));
6735 assert!(matches!(parse_format_arg("badge"), Some(Format::Badge)));
6736 assert!(parse_format_arg("xml").is_none());
6737 assert!(parse_format_arg("").is_none());
6738 }
6739
6740 #[test]
6741 fn quiet_parsing_logic() {
6742 let parse = |s: &str| -> bool { s == "1" || s.eq_ignore_ascii_case("true") };
6743 assert!(parse("1"));
6744 assert!(parse("true"));
6745 assert!(parse("TRUE"));
6746 assert!(parse("True"));
6747 assert!(!parse("0"));
6748 assert!(!parse("false"));
6749 assert!(!parse("yes"));
6750 }
6751
6752 #[test]
6753 fn tracing_filter_defaults_to_warn_without_env() {
6754 assert_eq!(build_tracing_filter(None).to_string(), "warn");
6755 }
6756
6757 #[test]
6758 fn tracing_filter_respects_explicit_env_directives() {
6759 assert_eq!(build_tracing_filter(Some("info")).to_string(), "info");
6760 }
6761
6762 #[test]
6763 fn tracing_filter_treats_empty_env_as_off() {
6764 assert_eq!(build_tracing_filter(Some("")).to_string(), "off");
6765 assert_eq!(build_tracing_filter(Some(" ")).to_string(), "off");
6766 }
6767}