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