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