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