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 baseline_gate;
39mod cache_notice;
40mod check;
41mod ci;
42mod ci_template;
43mod cli_agent;
44mod cli_format;
45mod cli_hooks;
46mod cli_impact;
47mod cli_production;
48mod cli_report;
49mod cli_startup;
50pub use fallow_engine::codeowners;
51mod combined;
52mod config;
53mod coverage;
54mod discovery_note;
55mod doctor;
56mod dupes;
57mod exit_codes;
58pub mod explain;
59mod fix;
60mod flags;
61mod gates;
62mod guard;
63mod health;
64mod impact;
65mod init;
66mod inspect;
67mod json_style;
68mod license;
69mod list;
70mod migrate;
71mod onboarding;
72#[cfg(test)]
73mod output_envelope;
74mod output_runtime;
75mod path_util;
76mod plugin_check;
77mod rayon_pool;
78mod regression;
79pub mod report;
80mod requests;
81mod rule_pack;
82mod runtime_support;
83mod schema;
84mod scope_path;
85mod security;
86mod security_help;
87mod selector;
88mod setup_hooks;
89mod signal;
90mod similar_code_cli;
91mod similar_code_help;
92mod suppressions;
93mod task_matrix;
94mod telemetry;
95mod trace_chain;
96mod trace_error;
97mod trace_path;
98mod type_aware_degrade;
99mod update_check;
100use fallow_engine::validate;
101use fallow_engine::vital_signs;
102mod cli_telemetry;
103mod viz;
104mod watch;
105mod write_scope;
106
107use check::{CheckOptions, IssueFilters, TraceOptions};
108pub(crate) mod error;
110use cli_agent::{AgentCli, run_agent_command};
111#[cfg(test)]
112use cli_format::parse_format_arg;
113use cli_format::{Format, FormatConfig};
114use cli_hooks::{HooksCli, run_hooks_command};
115use cli_impact::{ImpactCli, ImpactCrossRepoOpts, ImpactSortCli, dispatch_impact};
116use cli_production::{ProductionModes, resolve_production_modes};
117#[cfg(test)]
118use cli_startup::build_tracing_filter;
119use cli_startup::{
120 bare_combined_baseline_subcommand_error_message, bare_coverage_subcommand_error_message,
121 cli_bare_combined_baseline_flag, cli_has_bare_coverage_input, parse_cli_args,
122 reject_global_baseline_flags, run_pre_dispatch_checks, setup_tracing, validate_inputs,
123};
124#[cfg(test)]
125use cli_telemetry::TelemetryRun;
126#[cfg(test)]
127use cli_telemetry::{fallback_failure_reason_for, telemetry_workflow_for_command};
128use cli_telemetry::{record_run_epilogue, start_telemetry_run};
129use dupes::{DupesMode, DupesOptions};
130use error::emit_error;
131use health::{HealthOptions, SortBy};
132use list::ListOptions;
133pub(crate) use runtime_support::{AnalysisKind, GroupBy};
134pub(crate) use runtime_support::{
135 ConfigLoadOptions, LoadConfigArgs, build_ownership_resolver, load_config,
136 load_config_for_analysis,
137};
138#[cfg(test)]
139use security_help::{SECURITY_UNSUPPORTED_GLOBAL_LONGS, SecurityHelpTarget};
140use security_help::{render_security_help, security_help_target};
141use similar_code_help::{render_similar_code_help, similar_code_help_target};
142
143const DEFAULT_MIN_INVOCATIONS_HOT: u64 = 100;
144
145const TOP_LEVEL_HELP_TEMPLATE: &str =
146 "{about-with-newline}\n{usage-heading} {usage}{after-help}\n\nOptions:\n{options}";
147
148macro_rules! top_level_task_cheat_sheet {
151 () => {
152 "\
153When the agent is about to...
154 delete an \"unused\" export or file fallow dead-code --trace <file>:<export>
155 prove exact TypeScript symbol consumers fallow dead-code --type-aware --symbol-impact <file>:<export-or-class.method>
156 find how one module reaches another fallow trace --path <from> <to>
157 delete an \"unused\" dependency fallow dead-code --trace-dependency <name>
158 commit or open a PR fallow audit --base <ref>
159 read a diff before approving it fallow review --base <ref> --brief
160 prioritize refactoring fallow health --hotspots --targets
161 ask who owns code fallow health --ownership
162 check untested-but-reachable code fallow health --coverage-gaps
163 consolidate duplication fallow dupes --trace dup:<fingerprint>
164 find feature flags fallow flags
165 check architecture rules before editing fallow guard <files>
166 surface security candidates fallow security
167 inspect a target before editing fallow inspect --file <path>
168 understand a finding fallow explain <issue-type>
169 scope a monorepo --workspace <glob> / --changed-workspaces <ref>"
170 };
171}
172
173macro_rules! top_level_core_command_groups {
174 () => {
175 "\
176Analysis:
177 dead-code Analyze unused code, dependency hygiene, and architecture cycles
178 dupes Find copy-paste and structural code duplication
179 health Analyze complexity, maintainability, hotspots, and coverage gaps
180 flags Detect feature flag usage patterns
181 security Surface local security candidates for agent verification (opt-in)
182 similar-code Find semantic implementation overlap for verification (opt-in, local)
183 audit Review changed files for dead code, complexity, duplication, and styling
184
185Workflow:
186 watch Re-run analysis as files change
187 fix Auto-fix safe unused-code findings"
188 };
189}
190
191macro_rules! top_level_extended_command_groups {
192 () => {
193 "\
194Project inspection:
195 list List discovered files, entry points, plugins, boundaries, and workspaces
196 inspect Inspect one file or exported symbol as a bundled evidence query
197 trace Trace a symbol's call chain (best-effort, syntactic)
198 trace-error Resolve a runtime stack trace's frames to project definitions
199 guard Show which architecture rules apply to files before editing
200 decision-surface Surface the structural decisions a change embeds (advisory)
201 workspaces Show monorepo workspace discovery diagnostics
202 explain Explain one issue type without running analysis
203 suppressions List active fallow-ignore suppression markers
204 impact Show what fallow has done for you (opt-in, local-only)
205 viz Generate an interactive HTML map of the codebase
206
207Setup and configuration:
208 doctor Diagnose project readiness without changing anything
209 init Create a fallow config, optionally with a Git hook
210 agent Wire fallow into Claude Code, Codex, or Cursor in one pass
211 audit-cache Maintain reusable audit base-snapshot caches
212 recommend Recommend a project-tailored config for an agent to author
213 migrate Migrate knip, jscpd, or stylelint config to fallow
214 config Show the resolved config and loaded config file
215 config-schema Print the fallow config JSON Schema
216 plugin-schema Print the external plugin JSON Schema
217 plugin-check Dry-run external plugins and report what they seed
218 rule-pack Manage declarative rule packs (policy-as-code)
219 rule-pack-schema Print the rule pack JSON Schema
220 type-aware Inspect the optional TypeScript semantic companion
221
222Automation and CI:
223 ci Build PR/MR feedback envelopes
224 ci-template Print or vendor CI integration templates
225 report Re-render saved JSON as GitHub or CodeClimate output
226 hooks Install or remove fallow-managed Git and agent hooks
227 setup-hooks Deprecated: use `agent install` or `hooks install --target agent`
228
229Runtime coverage:
230 coverage Set up or analyze runtime coverage data
231 license Manage the paid-feature license
232 telemetry Manage opt-in product telemetry
233
234Reference:
235 schema Dump the CLI interface as machine-readable JSON
236 help Print this message or the help of a command"
237 };
238}
239
240const TOP_LEVEL_AFTER_HELP: &str = concat!(
241 top_level_task_cheat_sheet!(),
242 "\n\n",
243 top_level_core_command_groups!(),
244 "\n\nRun fallow --help for the complete command list."
245);
246
247const TOP_LEVEL_AFTER_LONG_HELP: &str = concat!(
248 top_level_task_cheat_sheet!(),
249 "\n\n",
250 top_level_core_command_groups!(),
251 "\n\n",
252 top_level_extended_command_groups!(),
253 "\n\n",
254 "When no command is given, fallow runs dead-code + dupes + health together.\n",
255 "Use --only/--skip to select specific analyses."
256);
257
258#[derive(Parser)]
259#[command(
260 name = "fallow",
261 about = "Codebase analyzer for TypeScript/JavaScript: unused code, circular dependencies, code duplication, complexity hotspots, and architecture boundary violations",
262 version,
263 disable_version_flag = true,
264 help_template = TOP_LEVEL_HELP_TEMPLATE,
265 after_help = TOP_LEVEL_AFTER_HELP,
266 after_long_help = TOP_LEVEL_AFTER_LONG_HELP
267)]
268struct Cli {
269 #[command(subcommand)]
270 command: Option<Command>,
271
272 #[arg(value_name = "PATH")]
275 path: Option<PathBuf>,
276
277 #[arg(
281 short = 'v',
282 visible_short_alias = 'V',
283 long = "version",
284 action = clap::ArgAction::Version
285 )]
286 version: Option<bool>,
287
288 #[arg(short, long, global = true)]
290 root: Option<PathBuf>,
291
292 #[arg(short, long, global = true)]
294 config: Option<PathBuf>,
295
296 #[arg(hide_short_help = true, long, global = true)]
298 allow_remote_extends: bool,
299
300 #[arg(
302 short,
303 long,
304 visible_alias = "output",
305 global = true,
306 default_value = "human"
307 )]
308 format: Format,
309
310 #[arg(hide_short_help = true, long, global = true)]
312 pretty: bool,
313
314 #[arg(short, long, global = true)]
316 quiet: bool,
317
318 #[arg(hide_short_help = true, long, global = true)]
320 no_cache: bool,
321
322 #[arg(hide_short_help = true, long, global = true)]
324 threads: Option<usize>,
325
326 #[arg(long, visible_alias = "base", global = true)]
328 changed_since: Option<String>,
329
330 #[arg(
335 hide_short_help = true,
336 long = "diff-file",
337 value_name = "PATH",
338 global = true
339 )]
340 diff_file: Option<PathBuf>,
341
342 #[arg(hide_short_help = true, long = "diff-stdin", global = true)]
345 diff_stdin: bool,
346
347 #[arg(
354 hide_short_help = true,
355 long = "churn-file",
356 value_name = "PATH",
357 global = true
358 )]
359 churn_file: Option<PathBuf>,
360
361 #[arg(
368 hide_short_help = true,
369 long = "max-file-size",
370 value_name = "MB",
371 global = true
372 )]
373 max_file_size: Option<u32>,
374
375 #[arg(hide_short_help = true, long, global = true)]
378 baseline: Option<PathBuf>,
379
380 #[arg(
395 hide_short_help = true,
396 long = "baseline-mode",
397 value_enum,
398 global = true
399 )]
400 baseline_mode: Option<BaselineModeArg>,
401
402 #[arg(long, global = true, value_name = "RUN_ID", hide = true)]
408 parent_run: Option<String>,
409
410 #[arg(hide_short_help = true, long, global = true)]
414 save_baseline: Option<PathBuf>,
415
416 #[arg(long, global = true)]
419 production: bool,
420
421 #[arg(
425 hide_short_help = true,
426 long = "no-production",
427 global = true,
428 conflicts_with = "production"
429 )]
430 no_production: bool,
431
432 #[arg(hide_short_help = true, long = "production-dead-code")]
434 production_dead_code: bool,
435
436 #[arg(hide_short_help = true, long = "production-health")]
438 production_health: bool,
439
440 #[arg(hide_short_help = true, long = "production-dupes")]
442 production_dupes: bool,
443
444 #[arg(short, long, global = true, value_delimiter = ',')]
448 workspace: Option<Vec<String>>,
449
450 #[arg(long, global = true, value_name = "REF")]
453 changed_workspaces: Option<String>,
454
455 #[arg(hide_short_help = true, long, global = true)]
457 group_by: Option<GroupBy>,
458
459 #[arg(hide_short_help = true, long, global = true)]
461 performance: bool,
462
463 #[arg(hide_short_help = true, long, global = true)]
465 explain: bool,
466
467 #[arg(hide_short_help = true, long, global = true)]
470 explain_skipped: bool,
471
472 #[arg(hide_short_help = true, long, global = true)]
474 summary: bool,
475
476 #[arg(long, global = true)]
478 ci: bool,
479
480 #[arg(hide_short_help = true, long, global = true)]
482 fail_on_issues: bool,
483
484 #[arg(hide_short_help = true, long, global = true, value_name = "PATH")]
489 sarif_file: Option<PathBuf>,
490
491 #[arg(short = 'o', long, global = true, value_name = "PATH")]
497 output_file: Option<PathBuf>,
498
499 #[arg(
509 hide_short_help = true,
510 long = "report-path-prefix",
511 visible_alias = "annotations-path-prefix",
512 global = true,
513 value_name = "PREFIX"
514 )]
515 report_path_prefix: Option<String>,
516
517 #[arg(hide_short_help = true, long, global = true)]
519 fail_on_regression: bool,
520
521 #[arg(hide_short_help = true, long, global = true)]
535 fail_on_stale_baseline: bool,
536
537 #[arg(hide_short_help = true, long, global = true)]
547 fail_on_parse_error: bool,
548
549 #[arg(
551 hide_short_help = true,
552 long,
553 global = true,
554 value_name = "TOLERANCE",
555 default_value = "0"
556 )]
557 tolerance: String,
558
559 #[arg(hide_short_help = true, long, global = true, value_name = "PATH")]
561 regression_baseline: Option<PathBuf>,
562
563 #[expect(
568 clippy::option_option,
569 reason = "clap pattern: None=not passed, Some(None)=flag only (write to config), Some(Some(path))=write to file"
570 )]
571 #[arg(hide_short_help = true, long, global = true, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
572 save_regression_baseline: Option<Option<String>>,
573
574 #[arg(long, value_delimiter = ',')]
576 only: Vec<AnalysisKind>,
577
578 #[arg(long, value_delimiter = ',')]
580 skip: Vec<AnalysisKind>,
581
582 #[arg(hide_short_help = true, long = "dupes-mode", global = true)]
584 dupes_mode: Option<DupesMode>,
585
586 #[arg(hide_short_help = true, long = "dupes-near", global = true)]
588 dupes_near: bool,
589
590 #[arg(hide_short_help = true, long = "dupes-threshold", global = true)]
592 dupes_threshold: Option<f64>,
593
594 #[arg(hide_short_help = true, long = "dupes-min-tokens", global = true)]
596 dupes_min_tokens: Option<usize>,
597
598 #[arg(hide_short_help = true, long = "dupes-min-lines", global = true)]
600 dupes_min_lines: Option<usize>,
601
602 #[arg(hide_short_help = true, long = "dupes-min-occurrences", global = true, value_parser = parse_min_occurrences)]
604 dupes_min_occurrences: Option<usize>,
605
606 #[arg(hide_short_help = true, long = "dupes-skip-local", global = true)]
608 dupes_skip_local: bool,
609
610 #[arg(hide_short_help = true, long = "dupes-cross-language", global = true)]
612 dupes_cross_language: bool,
613
614 #[arg(hide_short_help = true, long = "dupes-ignore-imports", global = true)]
617 dupes_ignore_imports: bool,
618
619 #[arg(
622 hide_short_help = true,
623 long = "dupes-no-ignore-imports",
624 global = true,
625 conflicts_with = "dupes_ignore_imports"
626 )]
627 dupes_no_ignore_imports: bool,
628
629 #[arg(hide_short_help = true, long)]
631 score: bool,
632
633 #[arg(hide_short_help = true, long)]
635 trend: bool,
636
637 #[expect(
641 clippy::option_option,
642 reason = "clap pattern: None=not passed, Some(None)=default path, Some(Some(path))=custom path"
643 )]
644 #[arg(hide_short_help = true, long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
645 save_snapshot: Option<Option<String>>,
646
647 #[arg(hide_short_help = true, long, value_name = "PATH")]
650 coverage: Option<PathBuf>,
651
652 #[arg(hide_short_help = true, long = "coverage-root", value_name = "PATH")]
655 coverage_root: Option<PathBuf>,
656
657 #[arg(hide_short_help = true, long = "dupes-baseline", value_name = "PATH")]
660 dupes_baseline: Option<PathBuf>,
661
662 #[arg(hide_short_help = true, long = "health-baseline", value_name = "PATH")]
665 health_baseline: Option<PathBuf>,
666
667 #[arg(hide_short_help = true, long, global = true)]
669 include_entry_exports: bool,
670
671 #[arg(hide_short_help = true, long, global = true)]
674 type_aware: bool,
675
676 #[arg(
679 hide_short_help = true,
680 long,
681 global = true,
682 conflicts_with = "type_aware"
683 )]
684 no_type_aware: bool,
685
686 #[arg(hide_short_help = true, long, global = true, value_name = "PATH", action = clap::ArgAction::Append)]
688 type_aware_project: Vec<PathBuf>,
689
690 #[arg(hide_short_help = true, long, global = true, value_enum)]
692 type_aware_require: Option<TypeAwareRequireArg>,
693}
694
695impl Cli {
696 const fn type_aware_override(&self) -> Option<bool> {
700 if self.no_type_aware {
701 Some(false)
702 } else if self.type_aware {
703 Some(true)
704 } else {
705 None
706 }
707 }
708}
709
710#[derive(Clone, Copy, Subcommand)]
711enum TypeAwareCli {
712 Status,
714}
715
716#[derive(Subcommand)]
717enum Command {
718 #[command(name = "dead-code", alias = "check")]
720 Check {
721 #[arg(long)]
723 unused_files: bool,
724
725 #[arg(long)]
727 unused_exports: bool,
728
729 #[arg(long)]
731 unused_deps: bool,
732
733 #[arg(long)]
735 unused_types: bool,
736
737 #[arg(long)]
739 private_type_leaks: bool,
740
741 #[arg(long)]
743 deprecated_exports_in_use: bool,
744
745 #[arg(long)]
747 unused_enum_members: bool,
748
749 #[arg(long)]
751 unused_class_members: bool,
752
753 #[arg(long)]
755 unused_store_members: bool,
756
757 #[arg(long)]
759 unprovided_injects: bool,
760
761 #[arg(long)]
763 unrendered_components: bool,
764
765 #[arg(long)]
767 unused_component_props: bool,
768
769 #[arg(long)]
771 unused_component_emits: bool,
772
773 #[arg(long)]
775 unused_component_inputs: bool,
776
777 #[arg(long)]
779 unused_component_outputs: bool,
780
781 #[arg(long)]
783 unused_svelte_events: bool,
784
785 #[arg(long)]
787 unused_server_actions: bool,
788
789 #[arg(long)]
791 unused_load_data_keys: bool,
792
793 #[arg(long)]
795 unresolved_imports: bool,
796
797 #[arg(long)]
799 unlisted_deps: bool,
800
801 #[arg(long)]
803 duplicate_exports: bool,
804
805 #[arg(long)]
807 circular_deps: bool,
808
809 #[arg(long)]
811 re_export_cycles: bool,
812
813 #[arg(long)]
815 boundary_violations: bool,
816
817 #[arg(long)]
819 policy_violations: bool,
820
821 #[arg(long)]
823 stale_suppressions: bool,
824
825 #[arg(long)]
827 unused_catalog_entries: bool,
828
829 #[arg(long)]
831 empty_catalog_groups: bool,
832
833 #[arg(long)]
835 unresolved_catalog_references: bool,
836
837 #[arg(long)]
839 unused_dependency_overrides: bool,
840
841 #[arg(long)]
843 misconfigured_dependency_overrides: bool,
844
845 #[arg(long)]
847 include_dupes: bool,
848
849 #[arg(long, value_name = "FILE:EXPORT")]
851 trace: Option<String>,
852
853 #[arg(long, value_name = "PATH")]
855 trace_file: Option<String>,
856
857 #[arg(long, value_name = "PACKAGE")]
859 trace_dependency: Option<String>,
860
861 #[arg(long, value_name = "PATH")]
865 impact_closure: Option<String>,
866
867 #[arg(long, value_name = "FILE:EXPORT")]
869 symbol_impact: Option<String>,
870
871 #[arg(long)]
876 top: Option<usize>,
877
878 #[arg(long, value_name = "PATH")]
882 file: Vec<std::path::PathBuf>,
883
884 #[arg(value_name = "PATH")]
887 path: Option<std::path::PathBuf>,
888 },
889
890 Watch {
892 #[arg(long)]
894 no_clear: bool,
895 },
896
897 TypeAware {
899 #[command(subcommand)]
900 subcommand: TypeAwareCli,
901 },
902
903 #[command(override_help = doctor::HELP)]
910 Doctor,
911
912 SimilarCode {
918 #[command(subcommand)]
919 subcommand: Option<similar_code_cli::SimilarCodeSubcommand>,
920 #[arg(long, value_name = "0..1")]
922 threshold: Option<f64>,
923 #[arg(long, value_name = "N")]
925 min_lines: Option<usize>,
926 #[arg(long, value_name = "N")]
928 top: Option<usize>,
929 #[arg(long, value_name = "PATH")]
931 file: Vec<PathBuf>,
932
933 #[arg(value_name = "PATH")]
936 path: Option<PathBuf>,
937 },
938
939 Inspect {
941 #[arg(
943 long,
944 value_name = "PATH",
945 conflicts_with = "symbol",
946 required_unless_present = "symbol"
947 )]
948 file: Option<String>,
949
950 #[arg(long, value_name = "FILE:EXPORT", conflicts_with = "file")]
952 symbol: Option<String>,
953
954 #[arg(long)]
959 symbol_chain: bool,
960
961 #[arg(long)]
964 churn: bool,
965 },
966
967 Trace {
982 #[arg(value_name = "FILE:SYMBOL", required_unless_present = "path")]
985 symbol: Option<String>,
986
987 #[arg(
991 long,
992 num_args = 2,
993 value_names = ["FROM", "TO"],
994 conflicts_with_all = ["symbol", "callers", "callees", "depth"]
995 )]
996 path: Vec<String>,
997
998 #[arg(long)]
1001 callers: bool,
1002
1003 #[arg(long)]
1006 callees: bool,
1007
1008 #[arg(long, value_name = "N")]
1011 depth: Option<u32>,
1012 },
1013
1014 #[command(name = "trace-error")]
1033 TraceError {
1034 #[arg(value_name = "FILE")]
1038 trace_file: Option<String>,
1039 },
1040
1041 Fix {
1056 #[arg(long)]
1058 dry_run: bool,
1059
1060 #[arg(long, alias = "force")]
1062 yes: bool,
1063
1064 #[arg(long)]
1071 no_create_config: bool,
1072
1073 #[arg(value_name = "PATH")]
1077 path: Option<PathBuf>,
1078 },
1079
1080 Init {
1089 #[arg(long)]
1091 toml: bool,
1092
1093 #[arg(long, conflicts_with_all = ["toml", "hooks", "branch"])]
1095 agents: bool,
1096
1097 #[arg(long)]
1101 hooks: bool,
1102
1103 #[arg(long, requires = "hooks")]
1105 branch: Option<String>,
1106
1107 #[arg(long, conflicts_with_all = ["toml", "agents", "hooks", "branch"])]
1111 decline: bool,
1112 },
1113
1114 Hooks {
1121 #[command(subcommand)]
1122 subcommand: HooksCli,
1123 },
1124
1125 Agent {
1131 #[command(subcommand)]
1132 subcommand: AgentCli,
1133 },
1134
1135 Ci {
1137 #[command(subcommand)]
1138 subcommand: CiCli,
1139 },
1140
1141 ConfigSchema,
1143
1144 PluginSchema,
1146
1147 PluginCheck,
1149
1150 RulePackSchema,
1152
1153 RulePack {
1155 #[command(subcommand)]
1156 subcommand: RulePackCli,
1157 },
1158
1159 Guard {
1161 #[arg(required = true, num_args = 1..)]
1163 files: Vec<String>,
1164 },
1165
1166 Config {
1184 #[arg(long)]
1186 path: bool,
1187 },
1188
1189 Recommend,
1197
1198 List {
1200 #[arg(long)]
1202 entry_points: bool,
1203
1204 #[arg(long)]
1206 files: bool,
1207
1208 #[arg(long)]
1210 plugins: bool,
1211
1212 #[arg(long)]
1214 boundaries: bool,
1215
1216 #[arg(long)]
1220 workspaces: bool,
1221
1222 #[arg(value_name = "PATH")]
1225 path: Option<PathBuf>,
1226 },
1227
1228 Workspaces,
1234
1235 Dupes {
1237 #[arg(long)]
1240 mode: Option<DupesMode>,
1241
1242 #[arg(long)]
1244 near: bool,
1245
1246 #[arg(long)]
1249 min_tokens: Option<usize>,
1250
1251 #[arg(long)]
1254 min_lines: Option<usize>,
1255
1256 #[arg(long, value_parser = parse_min_occurrences)]
1261 min_occurrences: Option<usize>,
1262
1263 #[arg(long)]
1266 threshold: Option<f64>,
1267
1268 #[arg(long)]
1270 skip_local: bool,
1271
1272 #[arg(long)]
1274 cross_language: bool,
1275
1276 #[arg(long)]
1280 ignore_imports: bool,
1281
1282 #[arg(long, conflicts_with = "ignore_imports")]
1285 no_ignore_imports: bool,
1286
1287 #[arg(long)]
1297 top: Option<usize>,
1298
1299 #[arg(long)]
1303 no_fragments: bool,
1304
1305 #[arg(long, value_name = "FILE:LINE")]
1307 trace: Option<String>,
1308
1309 #[arg(value_name = "PATH")]
1312 path: Option<PathBuf>,
1313 },
1314
1315 Health {
1321 #[arg(long)]
1323 max_cyclomatic: Option<u16>,
1324
1325 #[arg(long)]
1327 max_cognitive: Option<u16>,
1328
1329 #[arg(long)]
1333 max_crap: Option<f64>,
1334
1335 #[arg(long)]
1337 top: Option<usize>,
1338
1339 #[arg(long, default_value = "cyclomatic")]
1341 sort: SortBy,
1342
1343 #[arg(long)]
1346 complexity: bool,
1347
1348 #[arg(long)]
1355 complexity_breakdown: bool,
1356
1357 #[arg(long)]
1362 file_scores: bool,
1363
1364 #[arg(long)]
1367 coverage_gaps: bool,
1368
1369 #[arg(long)]
1372 hotspots: bool,
1373
1374 #[arg(long)]
1378 ownership: bool,
1379
1380 #[arg(long, value_name = "MODE", value_enum)]
1385 ownership_emails: Option<EmailModeArg>,
1386
1387 #[arg(long)]
1390 targets: bool,
1391
1392 #[arg(long)]
1395 type_coupling: bool,
1396
1397 #[arg(long)]
1402 css: bool,
1403
1404 #[arg(long, value_enum)]
1407 effort: Option<EffortFilter>,
1408
1409 #[arg(long)]
1412 score: bool,
1413
1414 #[arg(long, value_name = "N")]
1423 min_score: Option<f64>,
1424
1425 #[arg(long, value_name = "LEVEL", value_enum)]
1431 min_severity: Option<HealthSeverityCli>,
1432
1433 #[arg(long)]
1437 report_only: bool,
1438
1439 #[arg(long, value_name = "DURATION")]
1442 since: Option<String>,
1443
1444 #[arg(long, value_name = "N")]
1446 min_commits: Option<u32>,
1447
1448 #[expect(
1453 clippy::option_option,
1454 reason = "clap pattern: None=not passed, Some(None)=flag only, Some(Some(path))=with value"
1455 )]
1456 #[arg(long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
1457 save_snapshot: Option<Option<String>>,
1458
1459 #[arg(long)]
1463 trend: bool,
1464
1465 #[arg(long, value_name = "PATH")]
1478 coverage: Option<PathBuf>,
1479
1480 #[arg(long, value_name = "PATH")]
1486 coverage_root: Option<PathBuf>,
1487
1488 #[arg(long, value_name = "PATH")]
1494 runtime_coverage: Option<PathBuf>,
1495
1496 #[arg(long, default_value_t = 100)]
1498 min_invocations_hot: u64,
1499
1500 #[arg(long, value_name = "N")]
1506 min_observation_volume: Option<u32>,
1507
1508 #[arg(long, value_name = "RATIO")]
1513 low_traffic_threshold: Option<f64>,
1514
1515 #[arg(value_name = "PATH")]
1518 path: Option<PathBuf>,
1519 },
1520
1521 Flags {
1528 #[arg(long)]
1530 top: Option<usize>,
1531 },
1532
1533 Suppressions {
1543 #[arg(long, value_name = "PATH")]
1545 file: Vec<std::path::PathBuf>,
1546 },
1547
1548 Explain {
1554 #[arg(required = true, num_args = 1.., value_name = "ISSUE_TYPE")]
1556 issue_type: Vec<String>,
1557 },
1558
1559 #[command(visible_alias = "review")]
1584 Audit {
1585 #[arg(long = "production-dead-code")]
1587 production_dead_code: bool,
1588
1589 #[arg(long = "production-health")]
1591 production_health: bool,
1592
1593 #[arg(long = "production-dupes")]
1595 production_dupes: bool,
1596
1597 #[arg(long)]
1600 dead_code_baseline: Option<PathBuf>,
1601
1602 #[arg(long)]
1605 health_baseline: Option<PathBuf>,
1606
1607 #[arg(long)]
1610 dupes_baseline: Option<PathBuf>,
1611
1612 #[arg(long)]
1616 max_crap: Option<f64>,
1617
1618 #[arg(long, value_name = "PATH")]
1623 coverage: Option<PathBuf>,
1624
1625 #[arg(long, value_name = "PATH")]
1629 coverage_root: Option<PathBuf>,
1630
1631 #[arg(long = "no-css")]
1633 no_css: bool,
1634
1635 #[arg(long)]
1639 css_deep: bool,
1640
1641 #[arg(long = "no-css-deep")]
1643 no_css_deep: bool,
1644
1645 #[arg(long, value_enum)]
1651 gate: Option<AuditGateArg>,
1652
1653 #[arg(long, value_name = "PATH")]
1662 runtime_coverage: Option<PathBuf>,
1663
1664 #[arg(long, default_value_t = 100)]
1667 min_invocations_hot: u64,
1668
1669 #[arg(long, value_name = "MARKER", hide = true)]
1674 gate_marker: Option<String>,
1675
1676 #[arg(long)]
1682 brief: bool,
1683
1684 #[arg(
1689 long,
1690 value_name = "N",
1691 default_value_t = audit_decision_surface::DEFAULT_DECISION_CAP
1692 )]
1693 max_decisions: usize,
1694
1695 #[arg(long, conflicts_with_all = ["walkthrough_file", "walkthrough"])]
1703 walkthrough_guide: bool,
1704
1705 #[arg(long, value_name = "PATH")]
1715 walkthrough_file: Option<PathBuf>,
1716
1717 #[arg(long, conflicts_with_all = ["walkthrough_guide", "walkthrough_file"])]
1723 walkthrough: bool,
1724
1725 #[arg(long, value_name = "PATH")]
1731 mark_viewed: Vec<PathBuf>,
1732
1733 #[arg(long)]
1737 show_cleared: bool,
1738
1739 #[arg(long)]
1745 show_deprioritized: bool,
1746
1747 #[arg(value_name = "PATH")]
1750 path: Option<PathBuf>,
1751 },
1752
1753 AuditCache {
1755 #[command(subcommand)]
1756 subcommand: AuditCacheCli,
1757 },
1758
1759 DecisionSurface {
1774 #[arg(
1777 long,
1778 value_name = "N",
1779 default_value_t = audit_decision_surface::DEFAULT_DECISION_CAP
1780 )]
1781 max_decisions: usize,
1782 },
1783
1784 Impact {
1794 #[command(subcommand)]
1795 subcommand: Option<ImpactCli>,
1796 #[arg(long)]
1800 all: bool,
1801 #[arg(long, value_enum, default_value_t = ImpactSortCli::Recent)]
1803 sort: ImpactSortCli,
1804 #[arg(long)]
1807 limit: Option<usize>,
1808 },
1809
1810 Security {
1841 #[command(subcommand)]
1842 subcommand: Option<SecuritySubcommand>,
1843 #[arg(long, value_name = "PATH")]
1850 runtime_coverage: Option<PathBuf>,
1851 #[arg(long, default_value_t = 100)]
1854 min_invocations_hot: u64,
1855 #[arg(long, value_name = "PATH")]
1859 file: Vec<std::path::PathBuf>,
1860 #[arg(long, value_name = "MODE")]
1866 gate: Option<security::SecurityGateArg>,
1867 #[arg(long)]
1869 surface: bool,
1870
1871 #[arg(value_name = "PATH")]
1874 path: Option<PathBuf>,
1875 },
1876
1877 Report {
1882 #[arg(long, value_name = "PATH")]
1885 from: PathBuf,
1886 },
1887 Schema,
1889
1890 CiTemplate {
1897 #[command(subcommand)]
1898 subcommand: CiTemplateCli,
1899 },
1900
1901 Migrate {
1903 #[arg(long, conflicts_with = "jsonc")]
1905 toml: bool,
1906
1907 #[arg(long)]
1915 jsonc: bool,
1916
1917 #[arg(long)]
1919 dry_run: bool,
1920
1921 #[arg(long, value_name = "PATH")]
1923 from: Option<PathBuf>,
1924 },
1925
1926 License {
1933 #[command(subcommand)]
1934 subcommand: LicenseCli,
1935 },
1936
1937 Telemetry {
1945 #[command(subcommand)]
1946 subcommand: TelemetryCli,
1947 },
1948
1949 Coverage {
1954 #[command(subcommand)]
1955 subcommand: CoverageCli,
1956 },
1957
1958 SetupHooks {
1970 #[arg(long, value_enum)]
1972 agent: Option<setup_hooks::HookAgentArg>,
1973
1974 #[arg(long)]
1976 dry_run: bool,
1977
1978 #[arg(long)]
1981 force: bool,
1982
1983 #[arg(long)]
1985 user: bool,
1986
1987 #[arg(long)]
1989 gitignore_claude: bool,
1990
1991 #[arg(long)]
1995 uninstall: bool,
1996 },
1997
1998 Viz {
2000 #[arg(long = "out", value_name = "PATH")]
2002 output: Option<PathBuf>,
2003
2004 #[arg(long)]
2006 no_open: bool,
2007
2008 #[arg(long = "viz-format", default_value = "html")]
2010 viz_format: viz::VizFormat,
2011 },
2012}
2013
2014#[derive(Subcommand)]
2015enum SecuritySubcommand {
2016 Survivors {
2018 #[arg(long, value_name = "PATH")]
2020 candidates: PathBuf,
2021 #[arg(long, value_name = "PATH")]
2023 verdicts: PathBuf,
2024 #[arg(long)]
2026 require_verdict_for_each_candidate: bool,
2027 },
2028 #[command(name = "blind-spots")]
2030 BlindSpots {
2031 #[arg(long, value_name = "PATH")]
2033 file: Vec<PathBuf>,
2034 },
2035}
2036
2037#[derive(clap::Subcommand)]
2038enum AuditCacheCli {
2039 Remove {
2045 #[arg(long)]
2047 dry_run: bool,
2048
2049 #[arg(long, alias = "force")]
2051 yes: bool,
2052 },
2053
2054 Prune {
2067 #[arg(long)]
2069 dry_run: bool,
2070
2071 #[arg(long, value_name = "N")]
2078 max_age_days: Option<u32>,
2079 },
2080}
2081
2082#[derive(clap::Subcommand)]
2083enum LicenseCli {
2084 Activate {
2089 #[arg(value_name = "JWT")]
2091 jwt: Option<String>,
2092
2093 #[arg(long, value_name = "PATH")]
2095 from_file: Option<PathBuf>,
2096
2097 #[arg(long, conflicts_with_all = ["jwt", "from_file"])]
2099 stdin: bool,
2100
2101 #[arg(long, requires = "email")]
2108 trial: bool,
2109
2110 #[arg(long, value_name = "ADDR")]
2112 email: Option<String>,
2113 },
2114 Status,
2116 Refresh {
2122 #[arg(long, value_name = "KEY")]
2132 api_key: Option<String>,
2133 },
2134 Deactivate,
2136}
2137
2138#[derive(Clone, Copy, clap::Subcommand)]
2139enum TelemetryCli {
2140 Status,
2142 Enable,
2144 Disable,
2146 Inspect {
2148 #[arg(long)]
2150 example: bool,
2151 },
2152}
2153
2154#[derive(clap::Subcommand)]
2155enum CiTemplateCli {
2156 Gitlab {
2158 #[arg(long, value_name = "DIR", num_args = 0..=1, default_missing_value = ".")]
2162 vendor: Option<PathBuf>,
2163
2164 #[arg(long)]
2166 force: bool,
2167 },
2168}
2169
2170#[derive(clap::Subcommand)]
2171enum CoverageCli {
2172 Setup {
2174 #[arg(short = 'y', long)]
2176 yes: bool,
2177
2178 #[arg(long)]
2180 non_interactive: bool,
2181
2182 #[arg(long)]
2184 json: bool,
2185 },
2186 Analyze {
2192 #[arg(long, value_name = "PATH", conflicts_with = "cloud")]
2196 runtime_coverage: Option<PathBuf>,
2197
2198 #[arg(long, visible_alias = "runtime-coverage-cloud")]
2200 cloud: bool,
2201
2202 #[arg(long, value_name = "KEY")]
2204 api_key: Option<String>,
2205
2206 #[arg(long, value_name = "URL")]
2208 api_endpoint: Option<String>,
2209
2210 #[arg(long, value_name = "OWNER/REPO")]
2216 repo: Option<String>,
2217
2218 #[arg(long, value_name = "ID")]
2220 project_id: Option<String>,
2221
2222 #[arg(long, value_name = "DAYS", default_value_t = 30)]
2224 coverage_period: u16,
2225
2226 #[arg(long, value_name = "ENV")]
2228 environment: Option<String>,
2229
2230 #[arg(long, value_name = "SHA")]
2232 commit_sha: Option<String>,
2233
2234 #[arg(long)]
2236 production: bool,
2237
2238 #[arg(long, default_value_t = 100)]
2240 min_invocations_hot: u64,
2241
2242 #[arg(long, value_name = "N")]
2244 min_observation_volume: Option<u32>,
2245
2246 #[arg(long, value_name = "RATIO")]
2248 low_traffic_threshold: Option<f64>,
2249
2250 #[arg(long)]
2252 top: Option<usize>,
2253
2254 #[arg(long)]
2256 blast_radius: bool,
2257
2258 #[arg(long)]
2260 importance: bool,
2261
2262 #[arg(long)]
2264 debug_unmatched: bool,
2265 },
2266 UploadInventory {
2277 #[arg(long, value_name = "KEY")]
2286 api_key: Option<String>,
2287
2288 #[arg(long, value_name = "URL")]
2293 api_endpoint: Option<String>,
2294
2295 #[arg(long, value_name = "PROJECT_ID")]
2300 project_id: Option<String>,
2301
2302 #[arg(long, value_name = "SHA")]
2307 git_sha: Option<String>,
2308
2309 #[arg(long)]
2315 allow_dirty: bool,
2316
2317 #[arg(long, value_name = "GLOB", num_args = 0..)]
2321 exclude_paths: Vec<String>,
2322
2323 #[arg(long, value_name = "PREFIX")]
2336 path_prefix: Option<String>,
2337
2338 #[arg(long)]
2340 dry_run: bool,
2341
2342 #[arg(long)]
2348 with_callers: bool,
2349
2350 #[arg(long)]
2354 ignore_upload_errors: bool,
2355 },
2356 UploadSourceMaps {
2369 #[arg(long, value_name = "PATH", default_value = "dist")]
2371 dir: PathBuf,
2372
2373 #[arg(long, value_name = "GLOB", default_value = "**/*.map")]
2375 include: String,
2376
2377 #[arg(long, value_name = "GLOB", default_value = "**/node_modules/**")]
2381 exclude: Vec<String>,
2382
2383 #[arg(long, value_name = "NAME")]
2387 repo: Option<String>,
2388
2389 #[arg(long, value_name = "SHA")]
2394 git_sha: Option<String>,
2395
2396 #[arg(long, value_name = "URL")]
2398 endpoint: Option<String>,
2399
2400 #[arg(long, value_name = "BOOL", default_value_t = true, action = clap::ArgAction::Set)]
2405 strip_path: bool,
2406
2407 #[arg(long)]
2409 dry_run: bool,
2410
2411 #[arg(long, value_name = "N", default_value_t = 4)]
2413 concurrency: usize,
2414
2415 #[arg(long)]
2417 fail_fast: bool,
2418 },
2419 UploadStaticFindings {
2426 #[arg(long, value_name = "KEY")]
2436 api_key: Option<String>,
2437
2438 #[arg(long, value_name = "URL")]
2443 api_endpoint: Option<String>,
2444
2445 #[arg(long, value_name = "PROJECT_ID")]
2450 project_id: Option<String>,
2451
2452 #[arg(long, value_name = "SHA")]
2457 git_sha: Option<String>,
2458
2459 #[arg(long)]
2465 allow_dirty: bool,
2466
2467 #[arg(long)]
2469 dry_run: bool,
2470
2471 #[arg(long)]
2475 ignore_upload_errors: bool,
2476 },
2477}
2478
2479#[derive(Subcommand)]
2480enum CiCli {
2481 PlanPrComment {
2483 #[arg(long)]
2485 body: PathBuf,
2486
2487 #[arg(long)]
2489 marker_id: String,
2490
2491 #[arg(long)]
2493 clean: bool,
2494
2495 #[arg(long)]
2497 existing_comment_id: Option<String>,
2498
2499 #[arg(long)]
2501 existing_body: Option<PathBuf>,
2502 },
2503
2504 PostPrComment {
2506 #[arg(long, value_enum)]
2508 provider: CiProviderArg,
2509
2510 #[arg(long)]
2512 pr: Option<String>,
2513
2514 #[arg(long)]
2516 mr: Option<String>,
2517
2518 #[arg(long)]
2520 body: PathBuf,
2521
2522 #[arg(long)]
2524 envelope: Option<PathBuf>,
2525
2526 #[arg(long)]
2528 marker_id: String,
2529
2530 #[arg(long)]
2532 clean: bool,
2533
2534 #[arg(long)]
2536 repo: Option<String>,
2537
2538 #[arg(long = "project-id")]
2540 project_id: Option<String>,
2541
2542 #[arg(long = "api-url")]
2544 api_url: Option<String>,
2545
2546 #[arg(long)]
2548 dry_run: bool,
2549 },
2550
2551 PostReview {
2553 #[arg(long, value_enum)]
2555 provider: CiProviderArg,
2556
2557 #[arg(long)]
2559 pr: Option<String>,
2560
2561 #[arg(long)]
2563 mr: Option<String>,
2564
2565 #[arg(long)]
2567 envelope: PathBuf,
2568
2569 #[arg(long)]
2571 repo: Option<String>,
2572
2573 #[arg(long = "project-id")]
2575 project_id: Option<String>,
2576
2577 #[arg(long = "api-url")]
2579 api_url: Option<String>,
2580
2581 #[arg(long)]
2583 dry_run: bool,
2584 },
2585
2586 PostCheckRun {
2588 #[arg(long, value_enum)]
2590 provider: CiProviderArg,
2591
2592 #[arg(long)]
2594 decision: PathBuf,
2595
2596 #[arg(long)]
2598 repo: String,
2599
2600 #[arg(long = "head-sha")]
2602 head_sha: String,
2603
2604 #[arg(long = "api-url")]
2606 api_url: Option<String>,
2607
2608 #[arg(long = "split-gates")]
2610 split_gates: bool,
2611
2612 #[arg(long)]
2614 dry_run: bool,
2615 },
2616
2617 ReconcileReview {
2619 #[arg(long, value_enum)]
2621 provider: CiProviderArg,
2622
2623 #[arg(long)]
2625 pr: Option<String>,
2626
2627 #[arg(long)]
2629 mr: Option<String>,
2630
2631 #[arg(long)]
2633 envelope: PathBuf,
2634
2635 #[arg(long)]
2637 repo: Option<String>,
2638
2639 #[arg(long = "project-id")]
2641 project_id: Option<String>,
2642
2643 #[arg(long = "api-url")]
2645 api_url: Option<String>,
2646
2647 #[arg(long)]
2649 dry_run: bool,
2650 },
2651}
2652
2653#[derive(Subcommand)]
2654enum RulePackCli {
2655 Init {
2657 name: Option<String>,
2659
2660 #[arg(long, default_value = "starter")]
2662 template: String,
2663
2664 #[arg(long, default_value = "rule-packs")]
2666 dir: String,
2667
2668 #[arg(long)]
2670 no_config: bool,
2671 },
2672
2673 List,
2675
2676 Test {
2678 pack: Option<PathBuf>,
2680 },
2681
2682 Schema,
2684}
2685
2686#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
2688pub enum BaselineModeArg {
2689 #[default]
2691 Count,
2692 Identity,
2695}
2696
2697impl From<BaselineModeArg> for fallow_engine::baseline::HealthBaselineMode {
2698 fn from(value: BaselineModeArg) -> Self {
2699 match value {
2700 BaselineModeArg::Count => Self::Count,
2701 BaselineModeArg::Identity => Self::Identity,
2702 }
2703 }
2704}
2705
2706#[derive(Clone, Copy, Debug, clap::ValueEnum)]
2707enum CiProviderArg {
2708 Github,
2709 Gitlab,
2710}
2711
2712#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2714enum TypeAwareRequireArg {
2715 BestEffort,
2717 Complete,
2719}
2720
2721impl From<TypeAwareRequireArg> for fallow_config::TypeAwareRequire {
2722 fn from(value: TypeAwareRequireArg) -> Self {
2723 match value {
2724 TypeAwareRequireArg::BestEffort => Self::BestEffort,
2725 TypeAwareRequireArg::Complete => Self::Complete,
2726 }
2727 }
2728}
2729
2730#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2732pub enum EffortFilter {
2733 Low,
2734 Medium,
2735 High,
2736}
2737
2738impl EffortFilter {
2739 const fn to_estimate(self) -> fallow_output::EffortEstimate {
2741 match self {
2742 Self::Low => fallow_output::EffortEstimate::Low,
2743 Self::Medium => fallow_output::EffortEstimate::Medium,
2744 Self::High => fallow_output::EffortEstimate::High,
2745 }
2746 }
2747}
2748
2749#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2751pub enum HealthSeverityCli {
2752 Moderate,
2753 High,
2754 Critical,
2755}
2756
2757impl HealthSeverityCli {
2758 const fn to_health_severity(self) -> fallow_output::FindingSeverity {
2760 match self {
2761 Self::Moderate => fallow_output::FindingSeverity::Moderate,
2762 Self::High => fallow_output::FindingSeverity::High,
2763 Self::Critical => fallow_output::FindingSeverity::Critical,
2764 }
2765 }
2766}
2767
2768#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2774pub enum EmailModeArg {
2775 Raw,
2777 Handle,
2779 Anonymized,
2781 #[value(hide = true)]
2783 Hash,
2784}
2785
2786impl EmailModeArg {
2787 const fn to_config(self) -> fallow_config::EmailMode {
2789 match self {
2790 Self::Raw => fallow_config::EmailMode::Raw,
2791 Self::Handle => fallow_config::EmailMode::Handle,
2792 Self::Anonymized => fallow_config::EmailMode::Anonymized,
2793 Self::Hash => fallow_config::EmailMode::Hash,
2794 }
2795 }
2796}
2797
2798#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2800pub enum AuditGateArg {
2801 NewOnly,
2803 All,
2805}
2806
2807impl From<AuditGateArg> for fallow_config::AuditGate {
2808 fn from(value: AuditGateArg) -> Self {
2809 match value {
2810 AuditGateArg::NewOnly => Self::NewOnly,
2811 AuditGateArg::All => Self::All,
2812 }
2813 }
2814}
2815
2816fn parse_min_occurrences(s: &str) -> Result<usize, String> {
2820 let value: usize = s
2821 .parse()
2822 .map_err(|_| format!("`{s}` is not a non-negative integer"))?;
2823 if value < 2 {
2824 return Err(format!(
2825 "must be at least 2 (got {value}); a single occurrence isn't a duplicate"
2826 ));
2827 }
2828 Ok(value)
2829}
2830
2831fn resolve_audit_baseline_path(
2837 root: &std::path::Path,
2838 cli: Option<&std::path::Path>,
2839 config: Option<&str>,
2840) -> Option<PathBuf> {
2841 let path = cli.map(std::path::Path::to_path_buf).or_else(|| {
2842 config.map(|p| {
2843 let path = PathBuf::from(p);
2844 if path_util::is_absolute_path_any_platform(&path) {
2845 path
2846 } else {
2847 root.join(path)
2848 }
2849 })
2850 })?;
2851 if path_util::is_absolute_path_any_platform(&path) {
2852 Some(path)
2853 } else {
2854 Some(root.join(path))
2855 }
2856}
2857
2858fn emit_known_failure(
2859 message: &str,
2860 exit_code: u8,
2861 output: fallow_config::OutputFormat,
2862 reason: telemetry::FailureReason,
2863) -> ExitCode {
2864 telemetry::note_failure_reason(reason);
2865 emit_error(message, exit_code, output)
2866}
2867
2868fn emit_known_failure_with_style(
2869 message: &str,
2870 exit_code: u8,
2871 output: fallow_config::OutputFormat,
2872 json_style: json_style::JsonStyle,
2873 reason: telemetry::FailureReason,
2874) -> ExitCode {
2875 telemetry::note_failure_reason(reason);
2876 error::emit_error_with_style(message, exit_code, output, json_style)
2877}
2878
2879fn unsupported_security_global(cli: &Cli) -> Option<&'static str> {
2880 if cli.baseline.is_some() {
2881 Some("--baseline")
2882 } else if cli.save_baseline.is_some() {
2883 Some("--save-baseline")
2884 } else if cli.fail_on_stale_baseline {
2885 Some("--fail-on-stale-baseline")
2886 } else if cli.fail_on_parse_error {
2887 Some("--fail-on-parse-error")
2888 } else if cli.production {
2889 Some("--production")
2890 } else if cli.no_production {
2891 Some("--no-production")
2892 } else if cli.group_by.is_some() {
2893 Some("--group-by")
2894 } else if cli.performance {
2895 Some("--performance")
2896 } else if cli.explain_skipped {
2897 Some("--explain-skipped")
2898 } else if cli.fail_on_regression {
2899 Some("--fail-on-regression")
2900 } else if cli.regression_baseline.is_some() {
2901 Some("--regression-baseline")
2902 } else if cli.save_regression_baseline.is_some() {
2903 Some("--save-regression-baseline")
2904 } else if cli.dupes_mode.is_some() {
2905 Some("--dupes-mode")
2906 } else if cli.dupes_threshold.is_some() {
2907 Some("--dupes-threshold")
2908 } else if cli.dupes_min_tokens.is_some() {
2909 Some("--dupes-min-tokens")
2910 } else if cli.dupes_min_lines.is_some() {
2911 Some("--dupes-min-lines")
2912 } else if cli.dupes_min_occurrences.is_some() {
2913 Some("--dupes-min-occurrences")
2914 } else if cli.dupes_skip_local {
2915 Some("--dupes-skip-local")
2916 } else if cli.dupes_cross_language {
2917 Some("--dupes-cross-language")
2918 } else if cli.dupes_ignore_imports {
2919 Some("--dupes-ignore-imports")
2920 } else if cli.dupes_no_ignore_imports {
2921 Some("--dupes-no-ignore-imports")
2922 } else if cli.include_entry_exports {
2923 Some("--include-entry-exports")
2924 } else {
2925 None
2926 }
2927}
2928
2929struct DispatchContext<'a> {
2930 cli: &'a Cli,
2931 root: &'a std::path::Path,
2932 output: fallow_config::OutputFormat,
2933 quiet: bool,
2934 fail_on_issues: bool,
2935 json_style: json_style::JsonStyle,
2936 threads: usize,
2937 tolerance: regression::Tolerance,
2938 save_regression_file: Option<&'a std::path::PathBuf>,
2939 save_to_config: bool,
2940}
2941
2942impl DispatchContext<'_> {
2943 fn production_modes(
2944 &self,
2945 dead_code: bool,
2946 health: bool,
2947 dupes: bool,
2948 ) -> Result<ProductionModes, ExitCode> {
2949 resolve_production_modes(self.cli, self.root, self.output, dead_code, health, dupes)
2950 }
2951
2952 fn production_for(
2953 &self,
2954 analysis: fallow_config::ProductionAnalysis,
2955 ) -> Result<bool, ExitCode> {
2956 self.production_modes(false, false, false)
2957 .map(|modes| modes.for_analysis(analysis))
2958 }
2959
2960 fn regression_opts(&self, scoped: bool) -> regression::RegressionOpts<'_> {
2961 regression::RegressionOpts {
2962 fail_on_regression: self.cli.fail_on_regression,
2963 tolerance: self.tolerance,
2964 regression_baseline_file: self.cli.regression_baseline.as_deref(),
2965 save_target: if let Some(path) = self.save_regression_file {
2966 regression::SaveRegressionTarget::File(path)
2967 } else if self.save_to_config {
2968 regression::SaveRegressionTarget::Config
2969 } else {
2970 regression::SaveRegressionTarget::None
2971 },
2972 scoped,
2973 quiet: self.quiet,
2974 output: self.output,
2975 }
2976 }
2977}
2978
2979#[cfg(unix)]
2994fn signal_test_helper() -> ExitCode {
2995 use std::io::Write as _;
2996 use std::process::Command;
2997
2998 if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER_GRACEFUL").is_some() {
2999 signal::set_graceful_mode();
3000 }
3001
3002 let mut command = Command::new("sleep");
3003 command.arg("30");
3004 let child = match signal::ScopedChild::spawn(&mut command) {
3005 Ok(c) => c,
3006 Err(err) => {
3007 let _ = writeln!(std::io::stderr(), "spawn sleep failed: {err}");
3008 return ExitCode::from(2);
3009 }
3010 };
3011 let pid = child.id();
3012 let stdout = std::io::stdout();
3013 let mut lock = stdout.lock();
3014 let _ = writeln!(lock, "{pid}");
3015 let _ = lock.flush();
3016 drop(lock);
3017 let _ = child.wait_with_output();
3018 if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER_GRACEFUL").is_some() {
3019 return ExitCode::SUCCESS;
3020 }
3021 std::thread::sleep(std::time::Duration::from_secs(5));
3022 ExitCode::SUCCESS
3023}
3024
3025#[cfg(not(unix))]
3026fn signal_test_helper() -> ExitCode {
3027 ExitCode::from(2)
3028}
3029
3030fn install_spawn_hooks() {
3031 fallow_engine::churn::set_spawn_hook(signal::scoped_child::output);
3032 fallow_engine::changed_files::set_spawn_hook(signal::scoped_child::output);
3033}
3034
3035fn install_signal_handlers() {
3036 if let Err(err) = signal::install_handlers() {
3037 use std::io::Write as _;
3038 let stderr = std::io::stderr();
3039 let mut lock = stderr.lock();
3040 let _ = writeln!(lock, "fallow: failed to install signal handlers: {err}");
3041 }
3042}
3043
3044fn redirect_report_to_file(
3049 path: &std::path::Path,
3050 output: fallow_config::OutputFormat,
3051) -> Result<(), ExitCode> {
3052 match fallow_engine::write_guard::create_file(
3053 path,
3054 fallow_engine::write_guard::WriteTarget::Path,
3055 ) {
3056 Ok(file) => {
3057 report::sink::set_file_sink(file);
3058 colored::control::set_override(false);
3059 Ok(())
3060 }
3061 Err(e) if e.is_directory() => Err(emit_error(
3062 &format!(
3063 "failed to create the directory of {} for --output-file: {e}",
3064 path.display()
3065 ),
3066 2,
3067 output,
3068 )),
3069 Err(e) => Err(emit_error(
3070 &format!("failed to open {} for --output-file: {e}", path.display()),
3071 2,
3072 output,
3073 )),
3074 }
3075}
3076
3077fn finalize_report_file(
3080 path: &std::path::Path,
3081 quiet: bool,
3082 output: fallow_config::OutputFormat,
3083) -> Result<(), ExitCode> {
3084 if let Err(e) = report::sink::flush() {
3085 return Err(emit_error(
3086 &format!("failed to write {}: {e}", path.display()),
3087 2,
3088 output,
3089 ));
3090 }
3091 if !quiet && report::sink::wrote() {
3095 eprintln!("Report written to {}", path.display());
3096 }
3097 Ok(())
3098}
3099
3100pub fn run() -> ExitCode {
3105 install_signal_handlers();
3106 install_spawn_hooks();
3107
3108 if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER").is_some() {
3109 return signal_test_helper();
3110 }
3111
3112 let (mut cli, fmt) = match parse_cli_args() {
3113 Ok(parsed) => parsed,
3114 Err(code) => return code,
3115 };
3116 if cli.pretty && !fmt.payload_is_json {
3117 eprintln!(
3118 "Error: --pretty requires JSON output. Use --format json --pretty, or remove --pretty."
3119 );
3120 return ExitCode::from(2);
3121 }
3122
3123 if let Some(code) = reject_global_baseline_flags(&cli, &fmt) {
3124 return code;
3125 }
3126
3127 if let Some(code) = run_schema_command_if_requested(&cli, fmt.json_style) {
3128 return code;
3129 }
3130
3131 if let Some(code) = run_telemetry_command_if_requested(&mut cli, fmt.output, fmt.json_style) {
3132 return code;
3133 }
3134 if let Some(code) = run_doctor_command_if_requested(&cli, &fmt) {
3135 return code;
3136 }
3137 if is_impact_statusline(&cli) {
3138 let (root, _) = match validate_inputs(&cli, fmt.output, fmt.json_style) {
3139 Ok(validated) => validated,
3140 Err(code) => return code,
3141 };
3142 return cli_impact::render_impact_statusline(&root);
3143 }
3144 let telemetry_run = start_telemetry_run(&cli, &fmt);
3145
3146 let (root, threads) = match validate_inputs(&cli, fmt.output, fmt.json_style) {
3147 Ok(v) => v,
3148 Err(code) => {
3149 return record_run_epilogue(telemetry_run, code, None, cli.parent_run.as_deref());
3150 }
3151 };
3152
3153 let FormatConfig {
3154 output,
3155 payload_is_json: _,
3156 quiet,
3157 fail_on_issues,
3158 json_style,
3159 } = fmt;
3160
3161 let tolerance =
3162 match run_pre_dispatch_checks(&cli, &root, output, json_style, quiet, telemetry_run) {
3163 Ok(tolerance) => tolerance,
3164 Err(code) => return code,
3165 };
3166
3167 if let Some(note) = write_scope::default_cache_dir_note(&cli, &root) {
3168 eprintln!("{note}");
3169 cli.no_cache = true;
3170 }
3171
3172 let (save_regression_file, save_to_config) = regression_save_targets(&cli);
3173
3174 let command = cli.command.take();
3175 let dispatch = DispatchContext {
3176 cli: &cli,
3177 root: &root,
3178 output,
3179 quiet,
3180 fail_on_issues,
3181 json_style,
3182 threads,
3183 tolerance,
3184 save_regression_file: save_regression_file.as_ref(),
3185 save_to_config,
3186 };
3187 let exit_code = match dispatch_and_finalize(&dispatch, command) {
3188 Ok(code) => code,
3189 Err(code) => return code,
3190 };
3191 record_run_epilogue(telemetry_run, exit_code, None, cli.parent_run.as_deref())
3192}
3193
3194#[doc(hidden)]
3198pub fn benchmark_fix_dry_run(root: &Path, threads: usize) -> (ExitCode, usize) {
3199 let config_path = None;
3200 fix::run_fix_with_count(&fix::FixOptions {
3201 root,
3202 config_path: &config_path,
3203 output: fallow_config::OutputFormat::Json,
3204 json_style: json_style::JsonStyle::Compact,
3205 no_cache: true,
3206 threads,
3207 quiet: true,
3208 emit_output: false,
3209 allow_remote_extends: false,
3210 dry_run: true,
3211 yes: false,
3212 production: false,
3213 no_create_config: true,
3214 type_aware: None,
3215 type_aware_projects: &[],
3216 type_aware_require: None,
3217 scope: None,
3218 })
3219}
3220
3221#[doc(hidden)]
3224pub use audit::AuditReviewBenchmarkCorpus;
3225
3226#[doc(hidden)]
3229pub fn create_audit_review_benchmark_corpus(
3230 root: &Path,
3231 changed_files: &[PathBuf],
3232 threads: usize,
3233) -> Result<AuditReviewBenchmarkCorpus, ExitCode> {
3234 audit::create_audit_review_benchmark_corpus(root, changed_files, threads)
3235}
3236
3237#[doc(hidden)]
3240pub fn benchmark_audit_review_brief_many_changed_files_json(
3241 corpus: &mut AuditReviewBenchmarkCorpus,
3242) -> (ExitCode, usize, usize, usize, usize, usize) {
3243 match audit::benchmark_audit_review_brief_many_changed_files_json(corpus) {
3244 Ok(result) => (
3245 ExitCode::SUCCESS,
3246 result.introduced_count,
3247 result.inherited_count,
3248 result.public_api_added_count,
3249 result.decision_count,
3250 result.rendered_bytes,
3251 ),
3252 Err(code) => (code, 0, 0, 0, 0, 0),
3253 }
3254}
3255
3256#[doc(hidden)]
3257pub use inspect::InspectBenchmarkCorpus;
3258
3259#[doc(hidden)]
3262pub fn create_inspect_benchmark_corpus(root: &Path, threads: usize) -> InspectBenchmarkCorpus {
3263 inspect::create_inspect_benchmark_corpus(root, threads)
3264}
3265
3266#[doc(hidden)]
3269pub fn benchmark_inspect_file_evidence_bundle_json(
3270 root: &Path,
3271 threads: usize,
3272 corpus: &InspectBenchmarkCorpus,
3273) -> (ExitCode, usize, usize) {
3274 match inspect::benchmark_inspect_file_evidence_bundle_json(root, threads, corpus) {
3275 Ok((child_call_count, rendered_bytes)) => {
3276 (ExitCode::SUCCESS, child_call_count, rendered_bytes)
3277 }
3278 Err(_) => (ExitCode::from(2), 0, 0),
3279 }
3280}
3281
3282#[doc(hidden)]
3285pub fn benchmark_dead_code_json(root: &Path, threads: usize) -> (ExitCode, usize, usize) {
3286 match check::benchmark_dead_code_json(root, threads) {
3287 Ok((issue_count, rendered_bytes)) => (ExitCode::SUCCESS, issue_count, rendered_bytes),
3288 Err(code) => (code, 0, 0),
3289 }
3290}
3291
3292#[doc(hidden)]
3295pub fn benchmark_security_json(root: &Path, threads: usize) -> (ExitCode, usize, usize) {
3296 match security::benchmark_security_json(root, threads) {
3297 Ok((finding_count, rendered_bytes)) => (ExitCode::SUCCESS, finding_count, rendered_bytes),
3298 Err(code) => (code, 0, 0),
3299 }
3300}
3301
3302#[doc(hidden)]
3303pub use security::{SecurityBlindSpotsBenchmarkResult, SecuritySurvivorsBenchmarkCorpus};
3304
3305#[doc(hidden)]
3308pub fn create_security_survivors_benchmark_corpus(
3309 root: &Path,
3310 threads: usize,
3311) -> Result<SecuritySurvivorsBenchmarkCorpus, ExitCode> {
3312 security::create_security_survivors_benchmark_corpus(root, threads)
3313}
3314
3315#[doc(hidden)]
3318pub fn benchmark_security_survivors_json(
3319 corpus: &SecuritySurvivorsBenchmarkCorpus,
3320) -> (ExitCode, usize, usize, usize, usize, usize) {
3321 match security::benchmark_security_survivors_json(corpus) {
3322 Ok((survivors, dismissed, needs_human_review, unverdicted, rendered_bytes)) => (
3323 ExitCode::SUCCESS,
3324 survivors,
3325 dismissed,
3326 needs_human_review,
3327 unverdicted,
3328 rendered_bytes,
3329 ),
3330 Err(_) => (ExitCode::from(2), 0, 0, 0, 0, 0),
3331 }
3332}
3333
3334#[doc(hidden)]
3337pub fn benchmark_security_blind_spots_json(
3338 root: &Path,
3339 diagnostics: &[fallow_types::results::SecurityUnresolvedCalleeDiagnostic],
3340) -> SecurityBlindSpotsBenchmarkResult {
3341 security::benchmark_security_blind_spots_json(root, diagnostics)
3342}
3343
3344#[doc(hidden)]
3347pub fn benchmark_list_json(root: &Path, threads: usize) -> (ExitCode, usize, usize, usize, usize) {
3348 match list::benchmark_list_json(root, threads) {
3349 Ok((file_count, entry_point_count, workspace_count, rendered_bytes)) => (
3350 ExitCode::SUCCESS,
3351 file_count,
3352 entry_point_count,
3353 workspace_count,
3354 rendered_bytes,
3355 ),
3356 Err(code) => (code, 0, 0, 0, 0),
3357 }
3358}
3359
3360#[doc(hidden)]
3363pub fn benchmark_list_boundaries_json(
3364 root: &Path,
3365 threads: usize,
3366) -> (ExitCode, usize, usize, usize, usize) {
3367 match list::benchmark_list_boundaries_json(root, threads) {
3368 Ok((zone_count, rule_count, matched_file_count, rendered_bytes)) => (
3369 ExitCode::SUCCESS,
3370 zone_count,
3371 rule_count,
3372 matched_file_count,
3373 rendered_bytes,
3374 ),
3375 Err(code) => (code, 0, 0, 0, 0),
3376 }
3377}
3378
3379#[doc(hidden)]
3382pub use watch::WatchFilterBenchmarkGlobalGitignore;
3383
3384#[doc(hidden)]
3387pub fn create_watch_filter_benchmark_global_gitignore() -> WatchFilterBenchmarkGlobalGitignore {
3388 watch::create_benchmark_global_gitignore()
3389}
3390
3391#[doc(hidden)]
3394pub fn benchmark_watch_filter_initialization(
3395 config: &fallow_config::ResolvedConfig,
3396 global_gitignore: &WatchFilterBenchmarkGlobalGitignore,
3397) -> (usize, usize) {
3398 watch::benchmark_filter_initialization(config, global_gitignore)
3399}
3400
3401#[doc(hidden)]
3404pub fn benchmark_viz_html(root: &Path, threads: usize) -> (ExitCode, usize, usize, usize) {
3405 match viz::benchmark_viz_html(root, threads) {
3406 Ok((file_count, edge_count, rendered_bytes)) => {
3407 (ExitCode::SUCCESS, file_count, edge_count, rendered_bytes)
3408 }
3409 Err(code) => (code, 0, 0, 0),
3410 }
3411}
3412
3413#[doc(hidden)]
3416pub fn benchmark_rule_pack_test_json(root: &Path, threads: usize) -> (ExitCode, usize, usize) {
3417 match rule_pack::benchmark_rule_pack_test_json(root, threads) {
3418 Ok((finding_count, rendered_bytes)) => (ExitCode::SUCCESS, finding_count, rendered_bytes),
3419 Err(code) => (code, 0, 0),
3420 }
3421}
3422
3423#[doc(hidden)]
3426pub fn benchmark_recommend_json(root: &Path) -> (ExitCode, usize, usize, bool, usize) {
3427 match onboarding::benchmark_recommend_json(root) {
3428 Ok((decision_count, framework_count, heterogeneous, rendered_bytes)) => (
3429 ExitCode::SUCCESS,
3430 decision_count,
3431 framework_count,
3432 heterogeneous,
3433 rendered_bytes,
3434 ),
3435 Err(_) => (ExitCode::from(2), 0, 0, false, 0),
3436 }
3437}
3438
3439#[doc(hidden)]
3442pub fn benchmark_runtime_coverage_analyze_json(
3443 root: &Path,
3444 runtime_coverage_path: &Path,
3445 response_bytes: &[u8],
3446 threads: usize,
3447) -> (ExitCode, usize, usize, usize, String) {
3448 match coverage::benchmark_local_json(root, runtime_coverage_path, response_bytes, threads) {
3449 Ok((finding_count, hot_path_count, request_bytes, rendered)) => (
3450 ExitCode::SUCCESS,
3451 finding_count,
3452 hot_path_count,
3453 request_bytes,
3454 rendered,
3455 ),
3456 Err(code) => (code, 0, 0, 0, String::new()),
3457 }
3458}
3459
3460fn is_impact_statusline(cli: &Cli) -> bool {
3463 matches!(
3464 cli.command.as_ref(),
3465 Some(Command::Impact {
3466 subcommand: Some(ImpactCli::Statusline),
3467 all: false,
3468 ..
3469 })
3470 )
3471}
3472
3473fn dispatch_and_finalize(
3477 dispatch: &DispatchContext<'_>,
3478 command: Option<Command>,
3479) -> Result<ExitCode, ExitCode> {
3480 let cli = dispatch.cli;
3481 let output = dispatch.output;
3482 let quiet = dispatch.quiet;
3483
3484 if let Some(path) = cli.output_file.as_deref()
3487 && let Err(code) = redirect_report_to_file(path, output)
3488 {
3489 return Err(code);
3490 }
3491
3492 let exit_code = if command.is_some() && cli_has_bare_coverage_input(cli) {
3493 emit_error(bare_coverage_subcommand_error_message(), 2, output)
3494 } else if command.is_some()
3495 && let Some(flag) = cli_bare_combined_baseline_flag(cli)
3496 {
3497 emit_error(
3498 &bare_combined_baseline_subcommand_error_message(flag),
3499 2,
3500 output,
3501 )
3502 } else {
3503 match command {
3504 None => dispatch_bare_command(dispatch),
3505 Some(cmd) => dispatch_subcommand(cmd, dispatch),
3506 }
3507 };
3508
3509 if let Some(path) = cli.output_file.as_deref()
3510 && let Err(code) = finalize_report_file(path, quiet, output)
3511 {
3512 return Err(code);
3513 }
3514 Ok(exit_code)
3515}
3516
3517fn run_telemetry_command_if_requested(
3518 cli: &mut Cli,
3519 output: fallow_config::OutputFormat,
3520 json_style: json_style::JsonStyle,
3521) -> Option<ExitCode> {
3522 if matches!(cli.command, Some(Command::Telemetry { .. }))
3523 && let Some(Command::Telemetry { subcommand }) = cli.command.take()
3524 {
3525 return Some(telemetry::run(
3526 map_telemetry_subcommand(subcommand),
3527 output,
3528 json_style,
3529 ));
3530 }
3531 None
3532}
3533
3534fn run_doctor_command_if_requested(cli: &Cli, format: &FormatConfig) -> Option<ExitCode> {
3539 if !matches!(cli.command, Some(Command::Doctor)) {
3540 return None;
3541 }
3542
3543 if let Some(flag) = unsupported_doctor_option(cli) {
3544 let message = if flag == "--output-file" {
3545 "--output-file is not valid with `fallow doctor`; doctor is read-only and writes its report to stdout".to_string()
3546 } else {
3547 format!("{flag} is not valid with `fallow doctor`.")
3548 };
3549 return Some(crate::error::emit_error_with_style(
3550 &message,
3551 2,
3552 format.output,
3553 format.json_style,
3554 ));
3555 }
3556 if let Err(code) = doctor::validate_output(format.output, format.json_style) {
3557 return Some(code);
3558 }
3559
3560 let root = cli.root.clone().unwrap_or_else(|| {
3561 std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
3562 });
3563 let config_path = cli.config.as_ref().map(|path| {
3564 if path_util::is_absolute_path_any_platform(path) {
3565 path.clone()
3566 } else {
3567 root.join(path)
3568 }
3569 });
3570 let report = doctor::collect_report(&root, config_path.as_deref());
3571 Some(doctor::render_report(
3572 &report,
3573 format.output,
3574 format.json_style,
3575 ))
3576}
3577
3578fn unsupported_doctor_option(cli: &Cli) -> Option<&'static str> {
3582 [
3583 (cli.allow_remote_extends, "--allow-remote-extends"),
3584 (cli.no_cache, "--no-cache"),
3585 (cli.threads.is_some(), "--threads"),
3586 (cli.changed_since.is_some(), "--changed-since"),
3587 (cli.diff_file.is_some(), "--diff-file"),
3588 (cli.diff_stdin, "--diff-stdin"),
3589 (cli.churn_file.is_some(), "--churn-file"),
3590 (cli.max_file_size.is_some(), "--max-file-size"),
3591 (cli.baseline.is_some(), "--baseline"),
3592 (cli.baseline_mode.is_some(), "--baseline-mode"),
3593 (cli.parent_run.is_some(), "--parent-run"),
3594 (cli.save_baseline.is_some(), "--save-baseline"),
3595 (cli.production, "--production"),
3596 (cli.no_production, "--no-production"),
3597 (cli.production_dead_code, "--production-dead-code"),
3598 (cli.production_health, "--production-health"),
3599 (cli.production_dupes, "--production-dupes"),
3600 (cli.workspace.is_some(), "--workspace"),
3601 (cli.changed_workspaces.is_some(), "--changed-workspaces"),
3602 (cli.group_by.is_some(), "--group-by"),
3603 (cli.performance, "--performance"),
3604 (cli.explain, "--explain"),
3605 (cli.explain_skipped, "--explain-skipped"),
3606 (cli.summary, "--summary"),
3607 (cli.ci, "--ci"),
3608 (cli.fail_on_issues, "--fail-on-issues"),
3609 (cli.sarif_file.is_some(), "--sarif-file"),
3610 (cli.output_file.is_some(), "--output-file"),
3611 (cli.report_path_prefix.is_some(), "--report-path-prefix"),
3612 (cli.fail_on_regression, "--fail-on-regression"),
3613 (cli.fail_on_stale_baseline, "--fail-on-stale-baseline"),
3614 (cli.fail_on_parse_error, "--fail-on-parse-error"),
3615 (cli.tolerance != "0", "--tolerance"),
3616 (cli.regression_baseline.is_some(), "--regression-baseline"),
3617 (
3618 cli.save_regression_baseline.is_some(),
3619 "--save-regression-baseline",
3620 ),
3621 (!cli.only.is_empty(), "--only"),
3622 (!cli.skip.is_empty(), "--skip"),
3623 (cli.dupes_mode.is_some(), "--dupes-mode"),
3624 (cli.dupes_near, "--dupes-near"),
3625 (cli.dupes_threshold.is_some(), "--dupes-threshold"),
3626 (cli.dupes_min_tokens.is_some(), "--dupes-min-tokens"),
3627 (cli.dupes_min_lines.is_some(), "--dupes-min-lines"),
3628 (
3629 cli.dupes_min_occurrences.is_some(),
3630 "--dupes-min-occurrences",
3631 ),
3632 (cli.dupes_skip_local, "--dupes-skip-local"),
3633 (cli.dupes_cross_language, "--dupes-cross-language"),
3634 (cli.dupes_ignore_imports, "--dupes-ignore-imports"),
3635 (cli.dupes_no_ignore_imports, "--dupes-no-ignore-imports"),
3636 (cli.score, "--score"),
3637 (cli.trend, "--trend"),
3638 (cli.save_snapshot.is_some(), "--save-snapshot"),
3639 (cli.coverage.is_some(), "--coverage"),
3640 (cli.coverage_root.is_some(), "--coverage-root"),
3641 (cli.dupes_baseline.is_some(), "--dupes-baseline"),
3642 (cli.health_baseline.is_some(), "--health-baseline"),
3643 (cli.include_entry_exports, "--include-entry-exports"),
3644 (cli.type_aware, "--type-aware"),
3645 (cli.no_type_aware, "--no-type-aware"),
3646 (!cli.type_aware_project.is_empty(), "--type-aware-project"),
3647 (cli.type_aware_require.is_some(), "--type-aware-require"),
3648 ]
3649 .into_iter()
3650 .find_map(|(used, flag)| used.then_some(flag))
3651}
3652
3653fn run_schema_command_if_requested(
3654 cli: &Cli,
3655 json_style: json_style::JsonStyle,
3656) -> Option<ExitCode> {
3657 match cli.command {
3658 Some(Command::Schema) => Some(schema::run_schema(json_style)),
3659 Some(Command::ConfigSchema) => Some(init::run_config_schema(json_style)),
3660 Some(Command::PluginSchema) => Some(init::run_plugin_schema(json_style)),
3661 Some(Command::RulePackSchema) => Some(init::run_rule_pack_schema(json_style)),
3662 _ => None,
3663 }
3664}
3665
3666fn regression_save_targets(cli: &Cli) -> (Option<std::path::PathBuf>, bool) {
3667 let save_file = cli.save_regression_baseline.as_ref().and_then(|opt| {
3668 opt.as_ref()
3669 .filter(|path| !path.is_empty())
3670 .map(std::path::PathBuf::from)
3671 });
3672 let save_to_config = cli.save_regression_baseline.is_some() && save_file.is_none();
3673 (save_file, save_to_config)
3674}
3675
3676fn dispatch_bare_command(dispatch: &DispatchContext<'_>) -> ExitCode {
3677 let cli = dispatch.cli;
3678 let (run_check, run_dupes, run_health) = combined::resolve_analyses(&cli.only, &cli.skip);
3679 let production = match dispatch.production_modes(
3680 cli.production_dead_code,
3681 cli.production_health,
3682 cli.production_dupes,
3683 ) {
3684 Ok(production) => production,
3685 Err(code) => return code,
3686 };
3687 let coverage_inputs = if run_health {
3692 match resolve_health_coverage_inputs(
3693 dispatch,
3694 cli.coverage.as_deref(),
3695 cli.coverage_root.as_deref(),
3696 ) {
3697 Ok(inputs) => inputs,
3698 Err(code) => return code,
3699 }
3700 } else {
3701 ResolvedHealthCoverageInputs::default()
3702 };
3703 run_bare_combined(
3704 dispatch,
3705 production,
3706 &coverage_inputs,
3707 BareAnalyses {
3708 run_check,
3709 run_dupes,
3710 run_health,
3711 },
3712 )
3713}
3714
3715#[derive(Clone, Copy)]
3717struct BareAnalyses {
3718 run_check: bool,
3719 run_dupes: bool,
3720 run_health: bool,
3721}
3722
3723fn run_bare_combined(
3726 dispatch: &DispatchContext<'_>,
3727 production: ProductionModes,
3728 coverage_inputs: &ResolvedHealthCoverageInputs,
3729 analyses: BareAnalyses,
3730) -> ExitCode {
3731 let cli = dispatch.cli;
3732 let (output, quiet, fail_on_issues) =
3733 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
3734 if cli.fail_on_parse_error && !analyses.run_check && !analyses.run_health {
3735 return error::emit_error_with_style(
3736 "--fail-on-parse-error needs the dead-code or health analysis, and this run analyzes neither. Include dead-code or health in --only or --skip, or remove the flag.",
3737 2,
3738 output,
3739 dispatch.json_style,
3740 );
3741 }
3742 let scope = match crate::scope_path::resolve_command_scope(
3743 dispatch.root,
3744 dispatch.output,
3745 cli.path.clone(),
3746 ) {
3747 Ok(scope) => scope.map(|resolved| resolved.absolute),
3748 Err(code) => return code,
3749 };
3750 let scoped_run = scope.is_some();
3751 combined::run_combined(&combined::CombinedOptions {
3752 root: dispatch.root,
3753 config_path: &cli.config,
3754 output,
3755 json_style: dispatch.json_style,
3756 no_cache: cli.no_cache,
3757 threads: dispatch.threads,
3758 quiet,
3759 allow_remote_extends: cli.allow_remote_extends,
3760 fail_on_issues,
3761 sarif_file: cli.sarif_file.as_deref(),
3762 changed_since: cli.changed_since.as_deref(),
3763 churn_file: cli.churn_file.as_deref(),
3764 baseline: cli.baseline.as_deref(),
3765 save_baseline: cli.save_baseline.as_deref(),
3766 dupes_baseline: cli.dupes_baseline.as_deref(),
3767 health_baseline: cli.health_baseline.as_deref(),
3768 health_baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
3769 health_baseline_mode_explicit: cli.baseline_mode.is_some(),
3770 fail_on_stale_baseline: cli.fail_on_stale_baseline,
3771 production: cli.production,
3772 production_dead_code: Some(production.dead_code),
3773 production_health: Some(production.health),
3774 production_dupes: Some(production.dupes),
3775 workspace: cli.workspace.as_deref(),
3776 changed_workspaces: cli.changed_workspaces.as_deref(),
3777 group_by: cli.group_by,
3778 type_aware: cli.type_aware_override(),
3779 type_aware_projects: &cli.type_aware_project,
3780 type_aware_require: cli.type_aware_require.map(Into::into),
3781 explain: cli.explain,
3782 explain_skipped: cli.explain_skipped,
3783 performance: cli.performance,
3784 summary: cli.summary,
3785 run_check: analyses.run_check,
3786 run_dupes: analyses.run_dupes,
3787 run_health: analyses.run_health,
3788 dupes_mode: cli.dupes_mode,
3789 dupes_near: cli.dupes_near,
3790 dupes_threshold: cli.dupes_threshold,
3791 dupes_min_tokens: cli.dupes_min_tokens,
3792 dupes_min_lines: cli.dupes_min_lines,
3793 dupes_min_occurrences: cli.dupes_min_occurrences,
3794 dupes_skip_local: cli.dupes_skip_local,
3795 dupes_cross_language: cli.dupes_cross_language,
3796 dupes_ignore_imports: resolve_ignore_imports(
3797 cli.dupes_ignore_imports,
3798 cli.dupes_no_ignore_imports,
3799 ),
3800 score: cli.score || cli.trend,
3801 trend: cli.trend,
3802 save_snapshot: cli.save_snapshot.as_ref(),
3803 coverage: coverage_inputs.coverage.as_deref(),
3804 coverage_root: coverage_inputs.coverage_root.as_deref(),
3805 include_entry_exports: cli.include_entry_exports,
3806 fail_on_parse_error: cli.fail_on_parse_error,
3807 scope,
3808 regression_opts: dispatch.regression_opts(
3809 cli.changed_since.is_some()
3810 || cli.workspace.is_some()
3811 || cli.changed_workspaces.is_some()
3812 || scoped_run,
3813 ),
3814 })
3815}
3816
3817#[allow(
3818 clippy::too_many_lines,
3819 reason = "the command router is intentionally an exhaustive top-level dispatch table"
3820)]
3821fn dispatch_subcommand(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3822 let cli = dispatch.cli;
3823 let root = dispatch.root;
3824 let output = dispatch.output;
3825 let quiet = dispatch.quiet;
3826 match command {
3827 check @ Command::Check { .. } => dispatch_check_command(check, dispatch),
3828 Command::Watch { no_clear } => dispatch_watch(dispatch, no_clear),
3829 Command::TypeAware { subcommand } => dispatch_type_aware_command(dispatch, subcommand),
3830 Command::Doctor => unreachable!("doctor bypasses the normal dispatch epilogue"),
3831 Command::SimilarCode {
3832 subcommand,
3833 threshold,
3834 min_lines,
3835 top,
3836 file,
3837 path,
3838 } => {
3839 let scope = match crate::scope_path::resolve_command_scope(
3840 dispatch.root,
3841 dispatch.output,
3842 path,
3843 ) {
3844 Ok(scope) => scope,
3845 Err(code) => return code,
3846 };
3847 similar_code_cli::run(similar_code_cli::SimilarCodeCliInput {
3848 root,
3849 config_path: cli.config.as_deref(),
3850 allow_remote_extends: cli.allow_remote_extends,
3851 no_cache: cli.no_cache,
3852 threads: dispatch.threads,
3853 changed_since: cli.changed_since.as_deref(),
3854 diff_file: cli.diff_file.as_deref(),
3855 workspace: cli.workspace.as_deref(),
3856 changed_workspaces: cli.changed_workspaces.as_deref(),
3857 explain: cli.explain,
3858 quiet,
3859 output,
3860 json_style: dispatch.json_style,
3861 threshold,
3862 min_lines,
3863 top,
3864 files: file,
3865 scope,
3866 subcommand,
3867 })
3868 }
3869 Command::Inspect {
3870 file,
3871 symbol,
3872 symbol_chain,
3873 churn,
3874 } => dispatch_inspect_command(dispatch, file, symbol, symbol_chain, churn),
3875 Command::Trace {
3876 symbol,
3877 path,
3878 callers,
3879 callees,
3880 depth,
3881 } => dispatch_trace_command(dispatch, symbol, &path, callers, callees, depth),
3882 Command::TraceError { trace_file } => {
3883 trace_error::run_trace_error(&trace_error::TraceErrorOptions {
3884 root: dispatch.root,
3885 config_path: &dispatch.cli.config,
3886 output: dispatch.output,
3887 json_style: dispatch.json_style,
3888 no_cache: dispatch.cli.no_cache,
3889 threads: dispatch.threads,
3890 quiet: dispatch.quiet,
3891 allow_remote_extends: dispatch.cli.allow_remote_extends,
3892 trace_file: trace_file.as_deref(),
3893 })
3894 }
3895 fix @ Command::Fix { .. } => dispatch_fix_command(&fix, dispatch),
3896 init @ Command::Init { .. } => dispatch_init_command(init, root, quiet),
3897 Command::Hooks { subcommand } => {
3898 run_hooks_command(root, subcommand, output, dispatch.json_style)
3899 }
3900 Command::Agent { subcommand } => dispatch_agent_command(dispatch, subcommand),
3901 Command::Ci { subcommand } => {
3902 ci::run(map_ci_subcommand(subcommand), output, dispatch.json_style)
3903 }
3904 Command::ConfigSchema => init::run_config_schema(dispatch.json_style),
3905 Command::PluginSchema => init::run_plugin_schema(dispatch.json_style),
3906 Command::PluginCheck => plugin_check::run_plugin_check(root, output, dispatch.json_style),
3907 Command::RulePackSchema => init::run_rule_pack_schema(dispatch.json_style),
3908 Command::RulePack { subcommand } => dispatch_rule_pack_command(dispatch, subcommand),
3909 Command::Guard { files } => dispatch_guard_command(dispatch, &files),
3910 Command::CiTemplate { subcommand } => dispatch_ci_template_command(subcommand),
3911 Command::Config { path } => config::run_config_with_options(config::RunConfigInput {
3912 root,
3913 explicit_config: cli.config.as_deref(),
3914 path_only: path,
3915 output,
3916 quiet,
3917 json_style: dispatch.json_style,
3918 load_options: fallow_config::ConfigLoadOptions {
3919 allow_remote_extends: cli.allow_remote_extends,
3920 },
3921 }),
3922 Command::Recommend => onboarding::run_recommend(root, output, dispatch.json_style),
3923 list @ (Command::Workspaces | Command::List { .. }) => {
3924 dispatch_list_command(&list, dispatch)
3925 }
3926 dupes @ Command::Dupes { .. } => dispatch_dupes_command(dupes, dispatch),
3927 health @ Command::Health { .. } => dispatch_health_command(health, dispatch),
3928 Command::Flags { top } => dispatch_flags_command(dispatch, top),
3929 Command::Suppressions { file } => dispatch_suppressions_command(dispatch, &file),
3930 Command::Explain { issue_type } => {
3931 explain::run_explain(&issue_type.join(" "), output, dispatch.json_style)
3932 }
3933 audit @ Command::Audit { .. } => dispatch_audit_command(audit, dispatch),
3934 Command::AuditCache { subcommand } => dispatch_audit_cache_command(dispatch, &subcommand),
3935 Command::DecisionSurface { max_decisions } => {
3936 dispatch_decision_surface(dispatch, max_decisions)
3937 }
3938 Command::Impact {
3939 subcommand,
3940 all,
3941 sort,
3942 limit,
3943 } => dispatch_impact(
3944 root,
3945 quiet,
3946 output,
3947 dispatch.json_style,
3948 subcommand,
3949 ImpactCrossRepoOpts { all, sort, limit },
3950 ),
3951 security @ Command::Security { .. } => dispatch_security_command(security, dispatch),
3952 Command::Viz {
3953 output: viz_output,
3954 no_open,
3955 viz_format,
3956 } => dispatch_viz(dispatch, viz_output.as_deref(), no_open, viz_format),
3957 Command::Report { from } => {
3958 cli_report::run_report(&from, output, root, cli.config.as_deref())
3959 }
3960 Command::Schema => unreachable!("handled above"),
3961 migrate @ Command::Migrate { .. } => dispatch_migrate_command(migrate, root),
3962 Command::License { subcommand } => {
3963 dispatch_license_command(subcommand, output, dispatch.json_style)
3964 }
3965 Command::Telemetry { .. } => unreachable!("handled before root validation"),
3966 Command::Coverage { subcommand } => dispatch_coverage_command(dispatch, &subcommand),
3967 setup_hooks @ Command::SetupHooks { .. } => {
3968 dispatch_setup_hooks_command(&setup_hooks, dispatch)
3969 }
3970 }
3971}
3972
3973fn dispatch_type_aware_command(
3974 dispatch: &DispatchContext<'_>,
3975 subcommand: TypeAwareCli,
3976) -> ExitCode {
3977 match subcommand {
3978 TypeAwareCli::Status => {
3979 let status = fallow_api::type_aware_status(dispatch.root);
3980 match dispatch.output {
3981 fallow_config::OutputFormat::Json => {
3982 let output = type_aware_status_output(dispatch.root, status);
3983 match fallow_output::serialize_type_aware_status_json_output(output) {
3984 Ok(value) => match dispatch.json_style.serialize(&value) {
3985 Ok(json) => {
3986 crate::report::sink::outln!("{json}");
3987 ExitCode::SUCCESS
3988 }
3989 Err(error) => emit_error(
3990 &format!("failed to serialize type-aware status: {error}"),
3991 2,
3992 dispatch.output,
3993 ),
3994 },
3995 Err(error) => emit_error(
3996 &format!("failed to build type-aware status: {error}"),
3997 2,
3998 dispatch.output,
3999 ),
4000 }
4001 }
4002 fallow_config::OutputFormat::Human => {
4003 if status.available {
4004 crate::report::sink::outln!(
4005 "{}",
4006 report::human_status_line(
4007 report::HumanStatus::Ok,
4008 format_args!(
4009 "Type-aware companion: available ({}, protocol {}, TypeScript {})",
4010 status.package_version.as_deref().unwrap_or("unknown"),
4011 status.protocol_version,
4012 status.backend_version.as_deref().unwrap_or("unknown"),
4013 )
4014 )
4015 );
4016 } else {
4017 crate::report::sink::outln!(
4018 "{}",
4019 report::human_status_line(
4020 report::HumanStatus::Inactive,
4021 "Type-aware companion: unavailable"
4022 )
4023 );
4024 if let Some(remediation) = status.remediation {
4025 crate::report::sink::outln!(
4026 "{}",
4027 report::human_status_line(
4028 report::HumanStatus::Warning,
4029 format_args!("Action: {remediation}")
4030 )
4031 );
4032 }
4033 }
4034 ExitCode::SUCCESS
4035 }
4036 _ => emit_error(
4037 "type-aware status supports human and json output",
4038 2,
4039 dispatch.output,
4040 ),
4041 }
4042 }
4043 }
4044}
4045
4046fn type_aware_status_output(
4047 root: &Path,
4048 status: fallow_api::TypeAwareStatus,
4049) -> fallow_output::TypeAwareStatusOutput {
4050 let companion_path = status.companion_path.as_deref().map(|path| {
4051 if let Ok(relative) = path.strip_prefix(root)
4052 && !relative.as_os_str().is_empty()
4053 {
4054 relative.to_string_lossy().replace('\\', "/")
4055 } else {
4056 path.file_name()
4057 .unwrap_or(path.as_os_str())
4058 .to_string_lossy()
4059 .into_owned()
4060 }
4061 });
4062 let remediation = status.remediation.map(|message| {
4063 let without_root = message.replace(root.to_string_lossy().as_ref(), ".");
4064 status.companion_path.as_deref().map_or_else(
4065 || without_root.clone(),
4066 |path| {
4067 without_root.replace(
4068 path.to_string_lossy().as_ref(),
4069 companion_path.as_deref().unwrap_or("fallow-type-aware"),
4070 )
4071 },
4072 )
4073 });
4074 fallow_output::TypeAwareStatusOutput {
4075 schema_version: fallow_types::envelope::SchemaVersion(
4076 fallow_output::TYPE_AWARE_STATUS_SCHEMA_VERSION,
4077 ),
4078 version: fallow_types::envelope::ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
4079 available: status.available,
4080 discovery_source: status.discovery_source.map(str::to_string),
4081 companion_path,
4082 package_version: status.package_version,
4083 protocol_version: status.protocol_version,
4084 backend_family: status.backend_family,
4085 backend_version: status.backend_version,
4086 remediation,
4087 }
4088}
4089
4090fn dispatch_check_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4092 let filters = check_issue_filters(&command);
4093 let Command::Check {
4094 include_dupes,
4095 trace,
4096 trace_file,
4097 trace_dependency,
4098 impact_closure,
4099 symbol_impact,
4100 top,
4101 file,
4102 path,
4103 ..
4104 } = command
4105 else {
4106 unreachable!("check dispatcher only handles check commands");
4107 };
4108
4109 let scope = match crate::scope_path::resolve_command_scope(dispatch.root, dispatch.output, path)
4110 {
4111 Ok(scope) => scope.map(|resolved| resolved.absolute),
4112 Err(code) => return code,
4113 };
4114
4115 dispatch_check(
4116 dispatch,
4117 &CheckDispatchArgs {
4118 filters,
4119 trace_opts: TraceOptions {
4120 trace_export: trace,
4121 trace_file,
4122 trace_dependency,
4123 impact_closure,
4124 symbol_impact,
4125 performance: dispatch.cli.performance,
4126 },
4127 include_dupes,
4128 type_aware: dispatch.cli.type_aware_override(),
4129 type_aware_project: dispatch.cli.type_aware_project.clone(),
4130 type_aware_require: dispatch.cli.type_aware_require,
4131 top,
4132 file,
4133 scope,
4134 },
4135 )
4136}
4137
4138fn check_issue_filters(command: &Command) -> IssueFilters {
4143 check_issue_filters_framework(command, &check_issue_filters_core(command))
4144}
4145
4146fn check_issue_filters_core(command: &Command) -> IssueFilters {
4149 let Command::Check {
4150 unused_files,
4151 unused_exports,
4152 unused_deps,
4153 unused_types,
4154 private_type_leaks,
4155 deprecated_exports_in_use,
4156 unused_enum_members,
4157 unused_class_members,
4158 unresolved_imports,
4159 unlisted_deps,
4160 duplicate_exports,
4161 circular_deps,
4162 re_export_cycles,
4163 boundary_violations,
4164 policy_violations,
4165 stale_suppressions,
4166 ..
4167 } = command
4168 else {
4169 unreachable!("check filter builder only handles check commands");
4170 };
4171
4172 let mut filters = IssueFilters::default();
4173 for (flag, active) in [
4174 ("--unused-files", *unused_files),
4175 ("--unused-exports", *unused_exports),
4176 ("--unused-deps", *unused_deps),
4177 ("--unused-types", *unused_types),
4178 ("--private-type-leaks", *private_type_leaks),
4179 ("--deprecated-exports-in-use", *deprecated_exports_in_use),
4180 ("--unused-enum-members", *unused_enum_members),
4181 ("--unused-class-members", *unused_class_members),
4182 ("--unresolved-imports", *unresolved_imports),
4183 ("--unlisted-deps", *unlisted_deps),
4184 ("--duplicate-exports", *duplicate_exports),
4185 ("--circular-deps", *circular_deps),
4186 ("--re-export-cycles", *re_export_cycles),
4187 ("--boundary-violations", *boundary_violations),
4188 ("--policy-violations", *policy_violations),
4189 ("--stale-suppressions", *stale_suppressions),
4190 ] {
4191 enable_check_filter(&mut filters, flag, active);
4192 }
4193 filters
4194}
4195
4196fn check_issue_filters_framework(command: &Command, base: &IssueFilters) -> IssueFilters {
4199 let Command::Check {
4200 unused_store_members,
4201 unprovided_injects,
4202 unrendered_components,
4203 unused_component_props,
4204 unused_component_emits,
4205 unused_component_inputs,
4206 unused_component_outputs,
4207 unused_svelte_events,
4208 unused_server_actions,
4209 unused_load_data_keys,
4210 unused_catalog_entries,
4211 empty_catalog_groups,
4212 unresolved_catalog_references,
4213 unused_dependency_overrides,
4214 misconfigured_dependency_overrides,
4215 ..
4216 } = command
4217 else {
4218 unreachable!("check filter builder only handles check commands");
4219 };
4220
4221 let mut filters = base.clone();
4222 for (flag, active) in [
4223 ("--unused-store-members", *unused_store_members),
4224 ("--unprovided-injects", *unprovided_injects),
4225 ("--unrendered-components", *unrendered_components),
4226 ("--unused-component-props", *unused_component_props),
4227 ("--unused-component-emits", *unused_component_emits),
4228 ("--unused-component-inputs", *unused_component_inputs),
4229 ("--unused-component-outputs", *unused_component_outputs),
4230 ("--unused-svelte-events", *unused_svelte_events),
4231 ("--unused-server-actions", *unused_server_actions),
4232 ("--unused-load-data-keys", *unused_load_data_keys),
4233 ("--unused-catalog-entries", *unused_catalog_entries),
4234 ("--empty-catalog-groups", *empty_catalog_groups),
4235 (
4236 "--unresolved-catalog-references",
4237 *unresolved_catalog_references,
4238 ),
4239 (
4240 "--unused-dependency-overrides",
4241 *unused_dependency_overrides,
4242 ),
4243 (
4244 "--misconfigured-dependency-overrides",
4245 *misconfigured_dependency_overrides,
4246 ),
4247 ] {
4248 enable_check_filter(&mut filters, flag, active);
4249 }
4250 filters
4251}
4252
4253fn enable_check_filter(filters: &mut IssueFilters, flag: &str, active: bool) {
4254 if active {
4255 assert!(
4256 filters.enable_cli_filter_flag(flag),
4257 "check command uses unregistered dead-code filter flag {flag}"
4258 );
4259 }
4260}
4261
4262fn dispatch_inspect_command(
4263 dispatch: &DispatchContext<'_>,
4264 file: Option<String>,
4265 symbol: Option<String>,
4266 symbol_chain: bool,
4267 churn: bool,
4268) -> ExitCode {
4269 let target = match (file, symbol) {
4270 (Some(file), None) => inspect::InspectTarget::File { file },
4271 (None, Some(symbol)) => match selector::parse_file_symbol_selector(&symbol) {
4272 Some((file, export_name)) => inspect::InspectTarget::Symbol {
4273 file: file.to_string(),
4274 export_name: export_name.to_string(),
4275 },
4276 None => {
4277 return emit_error(
4278 "--symbol must be formatted as FILE:EXPORT",
4279 2,
4280 dispatch.output,
4281 );
4282 }
4283 },
4284 _ => {
4285 return emit_error(
4286 "inspect requires exactly one of --file or --symbol",
4287 2,
4288 dispatch.output,
4289 );
4290 }
4291 };
4292
4293 let churn_config = if churn {
4294 match load_config_for_analysis(
4295 dispatch.root,
4296 &dispatch.cli.config,
4297 ConfigLoadOptions {
4298 output: dispatch.output,
4299 no_cache: dispatch.cli.no_cache,
4300 threads: dispatch.threads,
4301 production_override: None,
4302 quiet: dispatch.quiet,
4303 allow_remote_extends: dispatch.cli.allow_remote_extends,
4304 },
4305 fallow_config::ProductionAnalysis::Health,
4306 ) {
4307 Ok(config) => Some(config),
4308 Err(code) => return code,
4309 }
4310 } else {
4311 None
4312 };
4313
4314 inspect::run_inspect(&inspect::InspectOptions {
4315 root: dispatch.root,
4316 config_path: dispatch.cli.config.as_ref(),
4317 output: dispatch.output,
4318 json_style: dispatch.json_style,
4319 no_cache: dispatch.cli.no_cache,
4320 no_production: dispatch.cli.no_production,
4321 max_file_size: dispatch.cli.max_file_size,
4322 threads: dispatch.threads,
4323 quiet: dispatch.quiet,
4324 production: dispatch.cli.production,
4325 workspace: dispatch.cli.workspace.as_ref(),
4326 target,
4327 churn_cache_dir: churn_config
4328 .as_ref()
4329 .map(|config| config.cache_dir.as_path()),
4330 symbol_chain,
4331 type_aware: dispatch.cli.type_aware_override(),
4332 type_aware_projects: &dispatch.cli.type_aware_project,
4333 type_aware_require: dispatch.cli.type_aware_require.map(Into::into),
4334 })
4335}
4336
4337fn dispatch_trace_command(
4338 dispatch: &DispatchContext<'_>,
4339 symbol: Option<String>,
4340 path: &[String],
4341 callers: bool,
4342 callees: bool,
4343 depth: Option<u32>,
4344) -> ExitCode {
4345 if let [from, to] = path {
4346 return trace_path::run_trace_path(&trace_path::TracePathOptions {
4347 root: dispatch.root,
4348 config_path: &dispatch.cli.config,
4349 output: dispatch.output,
4350 json_style: dispatch.json_style,
4351 no_cache: dispatch.cli.no_cache,
4352 threads: dispatch.threads,
4353 quiet: dispatch.quiet,
4354 allow_remote_extends: dispatch.cli.allow_remote_extends,
4355 from,
4356 to,
4357 });
4358 }
4359 let Some(symbol) = symbol else {
4360 return emit_error(
4361 "trace requires a FILE:SYMBOL target or --path <FROM> <TO>",
4362 2,
4363 dispatch.output,
4364 );
4365 };
4366 trace_chain::run_trace(&trace_chain::TraceChainOptions {
4367 root: dispatch.root,
4368 config_path: &dispatch.cli.config,
4369 output: dispatch.output,
4370 json_style: dispatch.json_style,
4371 no_cache: dispatch.cli.no_cache,
4372 threads: dispatch.threads,
4373 quiet: dispatch.quiet,
4374 allow_remote_extends: dispatch.cli.allow_remote_extends,
4375 target: symbol,
4376 callers,
4377 callees,
4378 depth: depth.unwrap_or(fallow_types::trace_chain::DEFAULT_TRACE_DEPTH),
4379 })
4380}
4381
4382fn dispatch_security_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4383 let Command::Security {
4384 subcommand,
4385 runtime_coverage,
4386 min_invocations_hot,
4387 file,
4388 gate,
4389 surface,
4390 path,
4391 } = command
4392 else {
4393 unreachable!("security dispatcher only handles security commands");
4394 };
4395
4396 let scope = match crate::scope_path::resolve_command_scope(dispatch.root, dispatch.output, path)
4397 {
4398 Ok(scope) => scope.map(|resolved| resolved.absolute),
4399 Err(code) => return code,
4400 };
4401
4402 let gate = gate.map(security::SecurityGateArg::into_mode);
4403 let cli = dispatch.cli;
4404 let (output, _quiet, fail_on_issues) =
4405 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
4406 let derived_flags = SecurityDerivedFlagState {
4407 output,
4408 json_style: dispatch.json_style,
4409 ci: cli.ci,
4410 fail_on_issues,
4411 sarif_file: cli.sarif_file.as_deref(),
4412 summary: cli.summary,
4413 explain: cli.explain,
4414 runtime_coverage: runtime_coverage.as_deref(),
4415 min_invocations_hot,
4416 file: file.as_slice(),
4417 gate,
4418 surface,
4419 };
4420 if let Some(code) = try_run_security_survivors(subcommand.as_ref(), &derived_flags) {
4421 return code;
4422 }
4423
4424 let scoped_files = scoped_security_files(&file, subcommand.as_ref());
4425 run_security_blind_spots_or_default(
4426 dispatch,
4427 &SecurityRunInputs {
4428 scoped_files: &scoped_files,
4429 subcommand: &subcommand,
4430 runtime_coverage: runtime_coverage.as_deref(),
4431 min_invocations_hot,
4432 gate,
4433 surface,
4434 scope,
4435 },
4436 &derived_flags,
4437 )
4438}
4439
4440struct SecurityRunInputs<'a> {
4443 scoped_files: &'a [PathBuf],
4444 subcommand: &'a Option<SecuritySubcommand>,
4445 runtime_coverage: Option<&'a Path>,
4446 min_invocations_hot: u64,
4447 gate: Option<security::SecurityGateMode>,
4448 surface: bool,
4449 scope: Option<PathBuf>,
4450}
4451
4452fn run_security_blind_spots_or_default(
4454 dispatch: &DispatchContext<'_>,
4455 inputs: &SecurityRunInputs<'_>,
4456 derived_flags: &SecurityDerivedFlagState<'_>,
4457) -> ExitCode {
4458 let cli = dispatch.cli;
4459 let (output, quiet, fail_on_issues) =
4460 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
4461 let opts = security::SecurityOptions {
4462 root: dispatch.root,
4463 config_path: &cli.config,
4464 output,
4465 json_style: dispatch.json_style,
4466 no_cache: cli.no_cache,
4467 threads: dispatch.threads,
4468 quiet,
4469 allow_remote_extends: cli.allow_remote_extends,
4470 fail_on_issues,
4471 sarif_file: cli.sarif_file.as_deref(),
4472 summary: cli.summary,
4473 changed_since: cli.changed_since.as_deref(),
4474 use_shared_diff_index: true,
4475 workspace: cli.workspace.as_deref(),
4476 changed_workspaces: cli.changed_workspaces.as_deref(),
4477 file: inputs.scoped_files,
4478 surface: inputs.surface,
4479 scope: inputs.scope.clone(),
4480 gate: inputs.gate,
4481 runtime_coverage: inputs.runtime_coverage,
4482 min_invocations_hot: inputs.min_invocations_hot,
4483 explain: cli.explain,
4484 };
4485 if matches!(
4486 inputs.subcommand,
4487 Some(SecuritySubcommand::BlindSpots { .. })
4488 ) {
4489 if let Some(code) = validate_security_blind_spots_flags(derived_flags) {
4490 return code;
4491 }
4492 security::run_blind_spots(&opts)
4493 } else {
4494 security::run(&opts)
4495 }
4496}
4497
4498fn try_run_security_survivors(
4501 subcommand: Option<&SecuritySubcommand>,
4502 flags: &SecurityDerivedFlagState<'_>,
4503) -> Option<ExitCode> {
4504 let Some(SecuritySubcommand::Survivors {
4505 candidates,
4506 verdicts,
4507 require_verdict_for_each_candidate,
4508 }) = subcommand
4509 else {
4510 return None;
4511 };
4512 if let Some(code) = validate_security_survivors_flags(flags) {
4513 return Some(code);
4514 }
4515 Some(security::run_survivors(
4516 &security::SecuritySurvivorsOptions {
4517 output: flags.output,
4518 json_style: flags.json_style,
4519 candidates,
4520 verdicts,
4521 require_verdict_for_each_candidate: *require_verdict_for_each_candidate,
4522 },
4523 ))
4524}
4525
4526fn scoped_security_files(
4528 file: &[PathBuf],
4529 subcommand: Option<&SecuritySubcommand>,
4530) -> Vec<PathBuf> {
4531 let mut scoped_files = file.to_vec();
4532 if let Some(SecuritySubcommand::BlindSpots {
4533 file: blind_spot_files,
4534 }) = subcommand
4535 {
4536 scoped_files.extend(blind_spot_files.iter().cloned());
4537 }
4538 scoped_files
4539}
4540
4541struct SecurityDerivedFlagState<'a> {
4542 output: fallow_config::OutputFormat,
4543 json_style: json_style::JsonStyle,
4544 ci: bool,
4545 fail_on_issues: bool,
4546 sarif_file: Option<&'a Path>,
4547 summary: bool,
4548 explain: bool,
4549 runtime_coverage: Option<&'a Path>,
4550 min_invocations_hot: u64,
4551 file: &'a [PathBuf],
4552 gate: Option<security::SecurityGateMode>,
4553 surface: bool,
4554}
4555
4556fn validate_security_survivors_flags(flags: &SecurityDerivedFlagState<'_>) -> Option<ExitCode> {
4557 let flag = if flags.ci {
4558 Some("--ci")
4559 } else if flags.fail_on_issues {
4560 Some("--fail-on-issues")
4561 } else if flags.sarif_file.is_some() {
4562 Some("--sarif-file")
4563 } else if flags.summary {
4564 Some("--summary")
4565 } else if flags.explain {
4566 Some("--explain")
4567 } else if flags.runtime_coverage.is_some() {
4568 Some("--runtime-coverage")
4569 } else if flags.min_invocations_hot != DEFAULT_MIN_INVOCATIONS_HOT {
4570 Some("--min-invocations-hot")
4571 } else if !flags.file.is_empty() {
4572 Some("--file")
4573 } else if flags.gate.is_some() {
4574 Some("--gate")
4575 } else if flags.surface {
4576 Some("--surface")
4577 } else {
4578 None
4579 }?;
4580 Some(emit_error(
4581 &format!("{flag} is not valid with `fallow security survivors`."),
4582 2,
4583 flags.output,
4584 ))
4585}
4586
4587fn validate_security_blind_spots_flags(flags: &SecurityDerivedFlagState<'_>) -> Option<ExitCode> {
4588 let flag = if flags.ci {
4589 Some("--ci")
4590 } else if flags.fail_on_issues {
4591 Some("--fail-on-issues")
4592 } else if flags.sarif_file.is_some() {
4593 Some("--sarif-file")
4594 } else if flags.summary {
4595 Some("--summary")
4596 } else if flags.explain {
4597 Some("--explain")
4598 } else if flags.runtime_coverage.is_some() {
4599 Some("--runtime-coverage")
4600 } else if flags.min_invocations_hot != DEFAULT_MIN_INVOCATIONS_HOT {
4601 Some("--min-invocations-hot")
4602 } else if flags.gate.is_some() {
4603 Some("--gate")
4604 } else if flags.surface {
4605 Some("--surface")
4606 } else {
4607 None
4608 }?;
4609 Some(emit_error(
4610 &format!("{flag} is not valid with `fallow security blind-spots`."),
4611 2,
4612 flags.output,
4613 ))
4614}
4615
4616fn dispatch_dupes_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4617 let Command::Dupes {
4618 mode,
4619 near,
4620 min_tokens,
4621 min_lines,
4622 min_occurrences,
4623 threshold,
4624 skip_local,
4625 cross_language,
4626 ignore_imports,
4627 no_ignore_imports,
4628 top,
4629 no_fragments,
4630 trace,
4631 path,
4632 } = command
4633 else {
4634 unreachable!("dupes dispatcher only handles dupes commands");
4635 };
4636
4637 let scope = match crate::scope_path::resolve_command_scope(dispatch.root, dispatch.output, path)
4638 {
4639 Ok(scope) => scope.map(|resolved| resolved.absolute),
4640 Err(code) => return code,
4641 };
4642
4643 dispatch_dupes(
4644 dispatch,
4645 &DupesDispatchArgs {
4646 mode,
4647 near,
4648 min_tokens,
4649 min_lines,
4650 min_occurrences,
4651 threshold,
4652 skip_local,
4653 cross_language,
4654 ignore_imports,
4655 no_ignore_imports,
4656 top,
4657 no_fragments,
4658 trace,
4659 scope,
4660 },
4661 )
4662}
4663
4664fn dispatch_agent_command(dispatch: &DispatchContext<'_>, subcommand: AgentCli) -> ExitCode {
4665 run_agent_command(
4666 dispatch.root,
4667 dispatch.cli.root.is_some(),
4668 subcommand,
4669 dispatch.output,
4670 dispatch.json_style,
4671 )
4672}
4673
4674fn dispatch_init_command(command: Command, root: &Path, quiet: bool) -> ExitCode {
4675 let Command::Init {
4676 toml,
4677 agents,
4678 hooks,
4679 branch,
4680 decline,
4681 } = command
4682 else {
4683 unreachable!("init dispatcher only handles init commands");
4684 };
4685
4686 init::run_init(&init::InitOptions {
4687 root,
4688 use_toml: toml,
4689 agents,
4690 hooks,
4691 branch: branch.as_deref(),
4692 decline,
4693 quiet,
4694 })
4695}
4696
4697fn dispatch_fix_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4698 let Command::Fix {
4699 dry_run,
4700 yes,
4701 no_create_config,
4702 path,
4703 } = command
4704 else {
4705 unreachable!("fix dispatcher only handles fix commands");
4706 };
4707
4708 let scope = match crate::scope_path::resolve_command_scope(
4709 dispatch.root,
4710 dispatch.output,
4711 path.clone(),
4712 ) {
4713 Ok(scope) => scope.map(|resolved| resolved.absolute),
4714 Err(code) => return code,
4715 };
4716
4717 dispatch_fix(
4718 dispatch,
4719 &FixDispatchArgs {
4720 dry_run: *dry_run,
4721 yes: *yes,
4722 no_create_config: *no_create_config,
4723 scope,
4724 },
4725 )
4726}
4727
4728fn dispatch_list_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4729 match command {
4730 Command::Workspaces => dispatch_list(dispatch, &ListDispatchArgs::workspaces()),
4731 Command::List {
4732 entry_points,
4733 files,
4734 plugins,
4735 boundaries,
4736 workspaces,
4737 path,
4738 } => {
4739 let scope = match crate::scope_path::resolve_command_scope(
4740 dispatch.root,
4741 dispatch.output,
4742 path.clone(),
4743 ) {
4744 Ok(scope) => scope.map(|resolved| resolved.absolute),
4745 Err(code) => return code,
4746 };
4747 dispatch_list(
4748 dispatch,
4749 &ListDispatchArgs {
4750 entry_points: *entry_points,
4751 files: *files,
4752 plugins: *plugins,
4753 boundaries: *boundaries,
4754 workspaces: *workspaces,
4755 scope,
4756 },
4757 )
4758 }
4759 _ => unreachable!("list dispatcher only handles list commands"),
4760 }
4761}
4762
4763fn dispatch_migrate_command(command: Command, root: &Path) -> ExitCode {
4764 let Command::Migrate {
4765 toml,
4766 jsonc,
4767 dry_run,
4768 from,
4769 } = command
4770 else {
4771 unreachable!("migrate dispatcher only handles migrate commands");
4772 };
4773
4774 migrate::run_migrate(root, toml, jsonc, dry_run, from.as_deref())
4775}
4776
4777fn dispatch_license_command(
4778 subcommand: LicenseCli,
4779 output: fallow_config::OutputFormat,
4780 json_style: json_style::JsonStyle,
4781) -> ExitCode {
4782 license::run(&map_license_subcommand(subcommand), output, json_style)
4783}
4784
4785fn dispatch_ci_template_command(subcommand: CiTemplateCli) -> ExitCode {
4786 match subcommand {
4787 CiTemplateCli::Gitlab { vendor, force } => {
4788 ci_template::run_gitlab_template(&ci_template::GitlabTemplateOptions {
4789 vendor_dir: vendor,
4790 force,
4791 })
4792 }
4793 }
4794}
4795
4796fn dispatch_coverage_command(dispatch: &DispatchContext<'_>, subcommand: &CoverageCli) -> ExitCode {
4797 let cli = dispatch.cli;
4798 coverage::run(
4799 map_coverage_subcommand(subcommand, cli.explain),
4800 &coverage::RunContext {
4801 root: dispatch.root,
4802 config_path: &cli.config,
4803 output: dispatch.output,
4804 json_style: dispatch.json_style,
4805 quiet: dispatch.quiet,
4806 no_cache: cli.no_cache,
4807 threads: dispatch.threads,
4808 explain: cli.explain,
4809 allow_remote_extends: cli.allow_remote_extends,
4810 },
4811 )
4812}
4813
4814fn dispatch_health_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4815 let Command::Health {
4816 max_cyclomatic,
4817 max_cognitive,
4818 max_crap,
4819 top,
4820 sort,
4821 complexity,
4822 complexity_breakdown,
4823 file_scores,
4824 coverage_gaps,
4825 hotspots,
4826 ownership,
4827 ownership_emails,
4828 targets,
4829 type_coupling,
4830 css,
4831 effort,
4832 score,
4833 min_score,
4834 min_severity,
4835 report_only,
4836 since,
4837 min_commits,
4838 save_snapshot,
4839 trend,
4840 coverage,
4841 coverage_root,
4842 runtime_coverage,
4843 min_invocations_hot,
4844 min_observation_volume,
4845 low_traffic_threshold,
4846 path,
4847 } = command
4848 else {
4849 unreachable!("health dispatcher only handles health commands");
4850 };
4851
4852 let scope = match crate::scope_path::resolve_command_scope(dispatch.root, dispatch.output, path)
4853 {
4854 Ok(scope) => scope.map(|resolved| resolved.absolute),
4855 Err(code) => return code,
4856 };
4857
4858 let ownership = ownership || ownership_emails.is_some();
4859 let hotspots = hotspots || ownership;
4860 let args = HealthDispatchArgs {
4861 max_cyclomatic,
4862 max_cognitive,
4863 max_crap,
4864 top,
4865 sort,
4866 complexity,
4867 complexity_breakdown,
4868 file_scores,
4869 coverage_gaps,
4870 hotspots,
4871 ownership,
4872 ownership_emails: ownership_emails.map(EmailModeArg::to_config),
4873 targets,
4874 type_coupling,
4875 css,
4876 effort,
4877 score,
4878 min_score,
4879 min_severity: min_severity.map(HealthSeverityCli::to_health_severity),
4880 report_only,
4881 since: since.as_deref(),
4882 min_commits,
4883 save_snapshot: save_snapshot.as_ref(),
4884 trend,
4885 fail_on_stale_baseline: dispatch.cli.fail_on_stale_baseline,
4886 fail_on_parse_error: dispatch.cli.fail_on_parse_error,
4887 coverage: coverage.as_deref(),
4888 coverage_root: coverage_root.as_deref(),
4889 runtime_coverage: runtime_coverage.as_deref(),
4890 min_invocations_hot,
4891 min_observation_volume,
4892 low_traffic_threshold,
4893 scope,
4894 };
4895 dispatch_health(dispatch, &args)
4896}
4897
4898fn dispatch_setup_hooks_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4899 let Command::SetupHooks {
4900 agent,
4901 dry_run,
4902 force,
4903 user,
4904 gitignore_claude,
4905 uninstall,
4906 } = command
4907 else {
4908 unreachable!("setup-hooks dispatcher only handles setup-hooks commands");
4909 };
4910
4911 eprintln!(
4912 "warning: `fallow setup-hooks` is deprecated and will be removed in the next major; use `fallow agent install` or `fallow hooks install --target agent`."
4913 );
4914 setup_hooks::run_setup_hooks(&setup_hooks::SetupHooksOptions {
4915 root: dispatch.root,
4916 agent: *agent,
4917 dry_run: *dry_run,
4918 force: *force,
4919 user: *user,
4920 gitignore_claude: *gitignore_claude,
4921 uninstall: *uninstall,
4922 })
4923}
4924
4925fn dispatch_audit_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4926 let Command::Audit {
4927 production_dead_code,
4928 production_health,
4929 production_dupes,
4930 dead_code_baseline,
4931 health_baseline,
4932 dupes_baseline,
4933 max_crap,
4934 coverage,
4935 coverage_root,
4936 no_css,
4937 css_deep,
4938 no_css_deep,
4939 gate,
4940 runtime_coverage,
4941 min_invocations_hot,
4942 gate_marker,
4943 brief,
4944 max_decisions,
4945 walkthrough_guide,
4946 walkthrough_file,
4947 walkthrough,
4948 mark_viewed,
4949 show_cleared,
4950 show_deprioritized,
4951 path,
4952 } = command
4953 else {
4954 unreachable!("audit dispatcher only handles audit commands");
4955 };
4956
4957 let brief = brief || walkthrough_guide || walkthrough || walkthrough_file.is_some();
4960
4961 let scope = match crate::scope_path::resolve_command_scope(dispatch.root, dispatch.output, path)
4962 {
4963 Ok(scope) => scope.map(|resolved| resolved.absolute),
4964 Err(code) => return code,
4965 };
4966
4967 dispatch_audit(
4968 dispatch,
4969 &AuditDispatchArgs {
4970 production_dead_code,
4971 production_health,
4972 production_dupes,
4973 dead_code_baseline,
4974 health_baseline,
4975 dupes_baseline,
4976 max_crap,
4977 coverage,
4978 coverage_root,
4979 no_css,
4980 css_deep,
4981 no_css_deep,
4982 gate,
4983 runtime_coverage,
4984 min_invocations_hot,
4985 gate_marker,
4986 brief,
4987 max_decisions,
4988 walkthrough_guide,
4989 walkthrough_file,
4990 walkthrough,
4991 mark_viewed,
4992 show_cleared,
4993 show_deprioritized,
4994 scope,
4995 },
4996 )
4997}
4998
4999fn dispatch_audit_cache_command(
5000 dispatch: &DispatchContext<'_>,
5001 subcommand: &AuditCacheCli,
5002) -> ExitCode {
5003 match subcommand {
5004 AuditCacheCli::Remove { dry_run, yes } => {
5005 if !*dry_run && !*yes && !std::io::stdin().is_terminal() {
5006 return emit_error(
5007 "audit-cache remove requires --yes (or --force) in non-interactive environments. Use --dry-run to preview removal first, then pass --yes to confirm.",
5008 2,
5009 dispatch.output,
5010 );
5011 }
5012 match base_worktree::remove_reusable_audit_caches(dispatch.root, *dry_run) {
5013 Ok(report) => {
5014 let action = if *dry_run { "would remove" } else { "removed" };
5015 if matches!(dispatch.output, fallow_config::OutputFormat::Json) {
5016 let value = serde_json::json!({
5017 "kind": "audit-cache-remove",
5018 "schema_version": 1,
5019 "command": "audit-cache remove",
5020 "root": dispatch.root,
5021 "dry_run": report.dry_run,
5022 "found": report.found,
5023 "would_remove": report.found.saturating_sub(report.skipped),
5024 "removed": report.removed,
5025 "skipped": report.skipped,
5026 "complete": report.skipped == 0,
5027 });
5028 let output_code = report::emit_report_json(
5029 &value,
5030 "audit cache removal",
5031 dispatch.json_style,
5032 );
5033 if output_code != ExitCode::SUCCESS {
5034 return output_code;
5035 }
5036 } else if !dispatch.quiet {
5037 println!(
5038 "audit cache: {action} {}, skipped {} for {}",
5039 if *dry_run {
5040 report.found.saturating_sub(report.skipped)
5041 } else {
5042 report.removed
5043 },
5044 report.skipped,
5045 dispatch.root.display(),
5046 );
5047 }
5048 if report.skipped == 0 {
5049 ExitCode::SUCCESS
5050 } else {
5051 ExitCode::from(2)
5052 }
5053 }
5054 Err(error) => emit_error(
5055 &format!(
5056 "failed to remove audit caches for {}: {error}",
5057 dispatch.root.display()
5058 ),
5059 2,
5060 dispatch.output,
5061 ),
5062 }
5063 }
5064 AuditCacheCli::Prune {
5065 dry_run,
5066 max_age_days,
5067 } => audit_cache_prune::run_audit_cache_prune(&audit_cache_prune::AuditCachePruneOptions {
5068 root: dispatch.root,
5069 config_path: dispatch.cli.config.as_ref(),
5070 allow_remote_extends: dispatch.cli.allow_remote_extends,
5071 dry_run: *dry_run,
5072 max_age_days: *max_age_days,
5073 output: dispatch.output,
5074 json_style: dispatch.json_style,
5075 quiet: dispatch.quiet,
5076 }),
5077 }
5078}
5079
5080fn dispatch_flags_command(dispatch: &DispatchContext<'_>, top: Option<usize>) -> ExitCode {
5081 let cli = dispatch.cli;
5082 let root = dispatch.root;
5083 let output = dispatch.output;
5084 let quiet = dispatch.quiet;
5085 let threads = dispatch.threads;
5086 let production = match resolve_production_modes(cli, root, output, false, false, false) {
5087 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::DeadCode),
5088 Err(code) => return code,
5089 };
5090 flags::run_flags(&flags::FlagsOptions {
5091 root,
5092 config_path: &cli.config,
5093 output,
5094 json_style: dispatch.json_style,
5095 no_cache: cli.no_cache,
5096 threads,
5097 quiet,
5098 allow_remote_extends: cli.allow_remote_extends,
5099 production,
5100 workspace: cli.workspace.as_deref(),
5101 changed_workspaces: cli.changed_workspaces.as_deref(),
5102 changed_since: cli.changed_since.as_deref(),
5103 explain: cli.explain,
5104 top,
5105 })
5106}
5107
5108fn dispatch_suppressions_command(
5109 dispatch: &DispatchContext<'_>,
5110 file: &[std::path::PathBuf],
5111) -> ExitCode {
5112 let cli = dispatch.cli;
5113 let root = dispatch.root;
5114 let output = dispatch.output;
5115 let production = match resolve_production_modes(cli, root, output, false, false, false) {
5116 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::DeadCode),
5117 Err(code) => return code,
5118 };
5119 suppressions::run_suppressions(&suppressions::SuppressionsOptions {
5120 root,
5121 config_path: &cli.config,
5122 output,
5123 json_style: dispatch.json_style,
5124 no_cache: cli.no_cache,
5125 threads: dispatch.threads,
5126 quiet: dispatch.quiet,
5127 allow_remote_extends: cli.allow_remote_extends,
5128 production,
5129 workspace: cli.workspace.as_deref(),
5130 changed_workspaces: cli.changed_workspaces.as_deref(),
5131 changed_since: cli.changed_since.as_deref(),
5132 file,
5133 })
5134}
5135
5136fn dispatch_guard_command(dispatch: &DispatchContext<'_>, files: &[String]) -> ExitCode {
5137 guard::run_guard(&guard::GuardOptions {
5138 root: dispatch.root,
5139 config_path: &dispatch.cli.config,
5140 output: dispatch.output,
5141 json_style: dispatch.json_style,
5142 quiet: dispatch.quiet,
5143 allow_remote_extends: dispatch.cli.allow_remote_extends,
5144 files,
5145 })
5146}
5147
5148fn dispatch_rule_pack_command(dispatch: &DispatchContext<'_>, subcommand: RulePackCli) -> ExitCode {
5149 let ctx = rule_pack::RulePackContext {
5150 root: dispatch.root,
5151 config_path: &dispatch.cli.config,
5152 output: dispatch.output,
5153 json_style: dispatch.json_style,
5154 quiet: dispatch.quiet,
5155 no_cache: dispatch.cli.no_cache,
5156 threads: Some(dispatch.threads),
5157 allow_remote_extends: dispatch.cli.allow_remote_extends,
5158 };
5159 rule_pack::run(&map_rule_pack_subcommand(subcommand), &ctx)
5160}
5161
5162fn map_rule_pack_subcommand(subcommand: RulePackCli) -> rule_pack::RulePackSubcommand {
5163 match subcommand {
5164 RulePackCli::Init {
5165 name,
5166 template,
5167 dir,
5168 no_config,
5169 } => rule_pack::RulePackSubcommand::Init(rule_pack::InitArgs {
5170 name,
5171 template,
5172 dir,
5173 no_config,
5174 }),
5175 RulePackCli::List => rule_pack::RulePackSubcommand::List,
5176 RulePackCli::Test { pack } => {
5177 rule_pack::RulePackSubcommand::Test(rule_pack::TestArgs { pack })
5178 }
5179 RulePackCli::Schema => rule_pack::RulePackSubcommand::Schema,
5180 }
5181}
5182
5183fn map_license_subcommand(sub: LicenseCli) -> license::LicenseSubcommand {
5184 match sub {
5185 LicenseCli::Activate {
5186 jwt,
5187 from_file,
5188 stdin,
5189 trial,
5190 email,
5191 } => license::LicenseSubcommand::Activate(license::ActivateArgs {
5192 raw_jwt: jwt,
5193 from_file,
5194 from_stdin: stdin,
5195 trial,
5196 email,
5197 }),
5198 LicenseCli::Status => license::LicenseSubcommand::Status,
5199 LicenseCli::Refresh { api_key } => {
5200 license::LicenseSubcommand::Refresh(license::RefreshArgs { api_key })
5201 }
5202 LicenseCli::Deactivate => license::LicenseSubcommand::Deactivate,
5203 }
5204}
5205
5206fn map_telemetry_subcommand(sub: TelemetryCli) -> telemetry::TelemetryCommand {
5207 match sub {
5208 TelemetryCli::Status => telemetry::TelemetryCommand::Status,
5209 TelemetryCli::Enable => telemetry::TelemetryCommand::Enable,
5210 TelemetryCli::Disable => telemetry::TelemetryCommand::Disable,
5211 TelemetryCli::Inspect { example } => telemetry::TelemetryCommand::Inspect { example },
5212 }
5213}
5214
5215fn map_ci_subcommand(sub: CiCli) -> ci::CiCommand {
5216 match sub {
5217 command @ CiCli::PlanPrComment { .. } => map_ci_plan_pr_comment(command),
5218 command @ CiCli::PostPrComment { .. } => map_ci_post_pr_comment(command),
5219 command @ CiCli::PostReview { .. } => map_ci_post_review(command),
5220 command @ CiCli::PostCheckRun { .. } => map_ci_post_check_run(command),
5221 command @ CiCli::ReconcileReview { .. } => map_ci_reconcile_review(command),
5222 }
5223}
5224
5225fn map_ci_plan_pr_comment(command: CiCli) -> ci::CiCommand {
5226 let CiCli::PlanPrComment {
5227 body,
5228 marker_id,
5229 clean,
5230 existing_comment_id,
5231 existing_body,
5232 } = command
5233 else {
5234 unreachable!("ci plan-pr-comment mapper called with different variant");
5235 };
5236
5237 ci::CiCommand::PlanPrComment {
5238 body,
5239 marker_id,
5240 clean,
5241 existing_comment_id,
5242 existing_body,
5243 }
5244}
5245
5246fn map_ci_post_pr_comment(command: CiCli) -> ci::CiCommand {
5247 let CiCli::PostPrComment {
5248 provider,
5249 pr,
5250 mr,
5251 body,
5252 envelope,
5253 marker_id,
5254 clean,
5255 repo,
5256 project_id,
5257 api_url,
5258 dry_run,
5259 } = command
5260 else {
5261 unreachable!("ci post-pr-comment mapper called with different variant");
5262 };
5263
5264 ci::CiCommand::PostPrComment {
5265 provider: map_ci_provider(provider),
5266 target: pr.or(mr),
5267 body,
5268 envelope,
5269 marker_id,
5270 clean,
5271 repo,
5272 project_id,
5273 api_url,
5274 dry_run,
5275 }
5276}
5277
5278fn map_ci_post_review(command: CiCli) -> ci::CiCommand {
5279 let CiCli::PostReview {
5280 provider,
5281 pr,
5282 mr,
5283 envelope,
5284 repo,
5285 project_id,
5286 api_url,
5287 dry_run,
5288 } = command
5289 else {
5290 unreachable!("ci post-review mapper called with different variant");
5291 };
5292
5293 ci::CiCommand::PostReview {
5294 provider: map_ci_provider(provider),
5295 target: pr.or(mr),
5296 envelope,
5297 repo,
5298 project_id,
5299 api_url,
5300 dry_run,
5301 }
5302}
5303
5304fn map_ci_post_check_run(command: CiCli) -> ci::CiCommand {
5305 let CiCli::PostCheckRun {
5306 provider,
5307 decision,
5308 repo,
5309 head_sha,
5310 api_url,
5311 split_gates,
5312 dry_run,
5313 } = command
5314 else {
5315 unreachable!("ci post-check-run mapper called with different variant");
5316 };
5317
5318 ci::CiCommand::PostCheckRun {
5319 provider: map_ci_provider(provider),
5320 decision,
5321 repo,
5322 head_sha,
5323 api_url,
5324 split_gates,
5325 dry_run,
5326 }
5327}
5328
5329fn map_ci_reconcile_review(command: CiCli) -> ci::CiCommand {
5330 let CiCli::ReconcileReview {
5331 provider,
5332 pr,
5333 mr,
5334 envelope,
5335 repo,
5336 project_id,
5337 api_url,
5338 dry_run,
5339 } = command
5340 else {
5341 unreachable!("ci reconcile-review mapper called with different variant");
5342 };
5343
5344 ci::CiCommand::ReconcileReview {
5345 provider: map_ci_provider(provider),
5346 target: pr.or(mr),
5347 envelope,
5348 repo,
5349 project_id,
5350 api_url,
5351 dry_run,
5352 }
5353}
5354
5355fn map_ci_provider(provider: CiProviderArg) -> ci::CiProvider {
5356 match provider {
5357 CiProviderArg::Github => ci::CiProvider::Github,
5358 CiProviderArg::Gitlab => ci::CiProvider::Gitlab,
5359 }
5360}
5361
5362fn map_coverage_subcommand(sub: &CoverageCli, explain: bool) -> coverage::CoverageSubcommand {
5363 match sub {
5364 CoverageCli::Setup {
5365 yes,
5366 non_interactive,
5367 json,
5368 } => map_coverage_setup(*yes, *non_interactive, *json, explain),
5369 CoverageCli::Analyze { .. } => map_coverage_analyze(sub),
5370 CoverageCli::UploadInventory { .. } => map_coverage_upload_inventory(sub),
5371 CoverageCli::UploadSourceMaps { .. } => map_coverage_upload_source_maps(sub),
5372 CoverageCli::UploadStaticFindings { .. } => map_coverage_upload_static_findings(sub),
5373 }
5374}
5375
5376fn map_coverage_setup(
5377 yes: bool,
5378 non_interactive: bool,
5379 json: bool,
5380 explain: bool,
5381) -> coverage::CoverageSubcommand {
5382 coverage::CoverageSubcommand::Setup(coverage::SetupArgs {
5383 yes,
5384 non_interactive: non_interactive || json,
5385 json,
5386 explain,
5387 })
5388}
5389
5390fn map_coverage_analyze(sub: &CoverageCli) -> coverage::CoverageSubcommand {
5391 let CoverageCli::Analyze {
5392 runtime_coverage,
5393 cloud,
5394 api_key,
5395 api_endpoint,
5396 repo,
5397 project_id,
5398 coverage_period,
5399 environment,
5400 commit_sha,
5401 production,
5402 min_invocations_hot,
5403 min_observation_volume,
5404 low_traffic_threshold,
5405 top,
5406 blast_radius,
5407 importance,
5408 debug_unmatched,
5409 } = sub
5410 else {
5411 unreachable!("coverage analyze mapper called with non-analyze variant");
5412 };
5413 coverage::CoverageSubcommand::Analyze(coverage::AnalyzeArgs {
5414 runtime_coverage: runtime_coverage.clone(),
5415 cloud: *cloud,
5416 api_key: api_key.clone(),
5417 api_endpoint: api_endpoint.clone(),
5418 repo: repo.clone(),
5419 project_id: project_id.clone(),
5420 coverage_period: *coverage_period,
5421 environment: environment.clone(),
5422 commit_sha: commit_sha.clone(),
5423 production: *production,
5424 min_invocations_hot: *min_invocations_hot,
5425 min_observation_volume: *min_observation_volume,
5426 low_traffic_threshold: *low_traffic_threshold,
5427 top: *top,
5428 blast_radius: *blast_radius,
5429 importance: *importance,
5430 debug_unmatched: *debug_unmatched,
5431 })
5432}
5433
5434fn map_coverage_upload_inventory(sub: &CoverageCli) -> coverage::CoverageSubcommand {
5435 let CoverageCli::UploadInventory {
5436 api_key,
5437 api_endpoint,
5438 project_id,
5439 git_sha,
5440 allow_dirty,
5441 exclude_paths,
5442 path_prefix,
5443 dry_run,
5444 with_callers,
5445 ignore_upload_errors,
5446 } = sub
5447 else {
5448 unreachable!("coverage inventory mapper called with non-inventory variant");
5449 };
5450 coverage::CoverageSubcommand::UploadInventory(coverage::UploadInventoryArgs {
5451 api_key: api_key.clone(),
5452 api_endpoint: api_endpoint.clone(),
5453 project_id: project_id.clone(),
5454 git_sha: git_sha.clone(),
5455 allow_dirty: *allow_dirty,
5456 exclude_paths: exclude_paths.clone(),
5457 path_prefix: path_prefix.clone(),
5458 dry_run: *dry_run,
5459 with_callers: *with_callers,
5460 ignore_upload_errors: *ignore_upload_errors,
5461 })
5462}
5463
5464fn map_coverage_upload_source_maps(sub: &CoverageCli) -> coverage::CoverageSubcommand {
5465 let CoverageCli::UploadSourceMaps {
5466 dir,
5467 include,
5468 exclude,
5469 repo,
5470 git_sha,
5471 endpoint,
5472 strip_path,
5473 dry_run,
5474 concurrency,
5475 fail_fast,
5476 } = sub
5477 else {
5478 unreachable!("coverage source-map mapper called with non-source-map variant");
5479 };
5480 coverage::CoverageSubcommand::UploadSourceMaps(coverage::UploadSourceMapsArgs {
5481 dir: dir.clone(),
5482 include: include.clone(),
5483 exclude: exclude.clone(),
5484 repo: repo.clone(),
5485 git_sha: git_sha.clone(),
5486 endpoint: endpoint.clone(),
5487 strip_path: *strip_path,
5488 dry_run: *dry_run,
5489 concurrency: *concurrency,
5490 fail_fast: *fail_fast,
5491 })
5492}
5493
5494fn map_coverage_upload_static_findings(sub: &CoverageCli) -> coverage::CoverageSubcommand {
5495 let CoverageCli::UploadStaticFindings {
5496 api_key,
5497 api_endpoint,
5498 project_id,
5499 git_sha,
5500 allow_dirty,
5501 dry_run,
5502 ignore_upload_errors,
5503 } = sub
5504 else {
5505 unreachable!("coverage static-findings mapper called with non-static variant");
5506 };
5507 coverage::CoverageSubcommand::UploadStaticFindings(coverage::UploadStaticFindingsArgs {
5508 api_key: api_key.clone(),
5509 api_endpoint: api_endpoint.clone(),
5510 project_id: project_id.clone(),
5511 git_sha: git_sha.clone(),
5512 allow_dirty: *allow_dirty,
5513 dry_run: *dry_run,
5514 ignore_upload_errors: *ignore_upload_errors,
5515 })
5516}
5517
5518struct CheckDispatchArgs {
5519 filters: IssueFilters,
5520 trace_opts: TraceOptions,
5521 include_dupes: bool,
5522 type_aware: Option<bool>,
5523 type_aware_project: Vec<std::path::PathBuf>,
5524 type_aware_require: Option<TypeAwareRequireArg>,
5525 top: Option<usize>,
5526 file: Vec<std::path::PathBuf>,
5527 scope: Option<std::path::PathBuf>,
5528}
5529
5530#[derive(Clone)]
5531struct ListDispatchArgs {
5532 entry_points: bool,
5533 files: bool,
5534 plugins: bool,
5535 boundaries: bool,
5536 workspaces: bool,
5537 scope: Option<std::path::PathBuf>,
5538}
5539
5540impl ListDispatchArgs {
5541 fn workspaces() -> Self {
5542 Self {
5543 entry_points: false,
5544 files: false,
5545 plugins: false,
5546 boundaries: false,
5547 workspaces: true,
5548 scope: None,
5549 }
5550 }
5551}
5552
5553fn dispatch_viz(
5554 dispatch: &DispatchContext<'_>,
5555 output_path: Option<&std::path::Path>,
5556 no_open: bool,
5557 format: viz::VizFormat,
5558) -> ExitCode {
5559 let cli = dispatch.cli;
5560 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
5561 Ok(production) => production,
5562 Err(code) => return code,
5563 };
5564 viz::run_viz(&viz::VizOptions {
5565 root: dispatch.root,
5566 config_path: &cli.config,
5567 no_cache: cli.no_cache,
5568 threads: dispatch.threads,
5569 quiet: dispatch.quiet,
5570 production,
5571 allow_remote_extends: cli.allow_remote_extends,
5572 output_path,
5573 no_open,
5574 format,
5575 })
5576}
5577
5578fn dispatch_watch(dispatch: &DispatchContext<'_>, no_clear: bool) -> ExitCode {
5579 let cli = dispatch.cli;
5580 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
5581 Ok(production) => production,
5582 Err(code) => return code,
5583 };
5584 watch::run_watch(&watch::WatchOptions {
5585 root: dispatch.root,
5586 config_path: &cli.config,
5587 output: dispatch.output,
5588 json_style: dispatch.json_style,
5589 no_cache: cli.no_cache,
5590 threads: dispatch.threads,
5591 quiet: dispatch.quiet,
5592 allow_remote_extends: cli.allow_remote_extends,
5593 production,
5594 clear_screen: !no_clear,
5595 explain: cli.explain,
5596 include_entry_exports: cli.include_entry_exports,
5597 type_aware: cli.type_aware_override(),
5598 type_aware_projects: &cli.type_aware_project,
5599 type_aware_require: cli.type_aware_require.map(Into::into),
5600 })
5601}
5602
5603struct FixDispatchArgs {
5604 dry_run: bool,
5605 yes: bool,
5606 no_create_config: bool,
5607 scope: Option<std::path::PathBuf>,
5608}
5609
5610fn dispatch_fix(dispatch: &DispatchContext<'_>, args: &FixDispatchArgs) -> ExitCode {
5611 let cli = dispatch.cli;
5612 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
5613 Ok(production) => production,
5614 Err(code) => return code,
5615 };
5616 fix::run_fix(&fix::FixOptions {
5617 root: dispatch.root,
5618 config_path: &cli.config,
5619 output: dispatch.output,
5620 json_style: dispatch.json_style,
5621 no_cache: cli.no_cache,
5622 threads: dispatch.threads,
5623 quiet: dispatch.quiet,
5624 emit_output: true,
5625 allow_remote_extends: cli.allow_remote_extends,
5626 dry_run: args.dry_run,
5627 yes: args.yes,
5628 production,
5629 no_create_config: args.no_create_config,
5630 type_aware: cli.type_aware_override(),
5631 type_aware_projects: &cli.type_aware_project,
5632 type_aware_require: cli.type_aware_require.map(Into::into),
5633 scope: args.scope.clone(),
5634 })
5635}
5636
5637fn dispatch_list(dispatch: &DispatchContext<'_>, args: &ListDispatchArgs) -> ExitCode {
5638 let cli = dispatch.cli;
5639 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
5640 Ok(production) => production,
5641 Err(code) => return code,
5642 };
5643 list::run_list(&ListOptions {
5644 root: dispatch.root,
5645 config_path: &cli.config,
5646 output: dispatch.output,
5647 json_style: dispatch.json_style,
5648 threads: dispatch.threads,
5649 no_cache: cli.no_cache,
5650 entry_points: args.entry_points,
5651 files: args.files,
5652 plugins: args.plugins,
5653 boundaries: args.boundaries,
5654 workspaces: args.workspaces,
5655 production,
5656 allow_remote_extends: cli.allow_remote_extends,
5657 scope: args.scope.clone(),
5658 })
5659}
5660
5661fn dispatch_check(dispatch: &DispatchContext<'_>, args: &CheckDispatchArgs) -> ExitCode {
5662 let cli = dispatch.cli;
5663 let (output, quiet, fail_on_issues) =
5664 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
5665 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
5666 Ok(production) => production,
5667 Err(code) => return code,
5668 };
5669 if let Some(code) = validate_type_aware_check_options(dispatch, args) {
5670 return code;
5671 }
5672 check::run_check(&CheckOptions {
5673 root: dispatch.root,
5674 config_path: &cli.config,
5675 output,
5676 json_style: dispatch.json_style,
5677 no_cache: cli.no_cache,
5678 threads: dispatch.threads,
5679 quiet,
5680 allow_remote_extends: cli.allow_remote_extends,
5681 fail_on_issues,
5682 filters: &args.filters,
5683 changed_since: cli.changed_since.as_deref(),
5684 diff_index: None,
5685 use_shared_diff_index: true,
5686 baseline: cli.baseline.as_deref(),
5687 baseline_flag: "--baseline",
5688 save_baseline: cli.save_baseline.as_deref(),
5689 fail_on_stale_baseline: cli.fail_on_stale_baseline,
5690 sarif_file: cli.sarif_file.as_deref(),
5691 production,
5692 production_override: Some(production),
5693 workspace: cli.workspace.as_deref(),
5694 changed_workspaces: cli.changed_workspaces.as_deref(),
5695 group_by: cli.group_by,
5696 include_dupes: args.include_dupes,
5697 type_aware: args.type_aware,
5698 type_aware_config_override: None,
5699 type_aware_projects: &args.type_aware_project,
5700 type_aware_require: args.type_aware_require.map(Into::into),
5701 trace_opts: &args.trace_opts,
5702 explain: cli.explain,
5703 top: args.top,
5704 file: &args.file,
5705 scope: args.scope.clone(),
5706 include_entry_exports: cli.include_entry_exports,
5707 fail_on_parse_error: cli.fail_on_parse_error,
5708 summary: cli.summary,
5709 regression_opts: dispatch.regression_opts(
5710 cli.changed_since.is_some()
5711 || cli.workspace.is_some()
5712 || cli.changed_workspaces.is_some()
5713 || !args.file.is_empty()
5714 || args.scope.is_some(),
5715 ),
5716 retain_modules_for_health: false,
5717 defer_performance: false,
5718 analysis_snapshot: fallow_config::AnalysisSnapshot::Current,
5719 explain_skipped: cli.explain_skipped,
5720 })
5721}
5722
5723fn validate_type_aware_check_options(
5724 dispatch: &DispatchContext<'_>,
5725 args: &CheckDispatchArgs,
5726) -> Option<ExitCode> {
5727 let output = dispatch.output;
5728 if !args.type_aware_project.is_empty() && args.type_aware != Some(true) {
5729 return Some(emit_error(
5730 "--type-aware-project requires --type-aware",
5731 2,
5732 output,
5733 ));
5734 }
5735 if args.type_aware_require.is_some() && args.type_aware != Some(true) {
5736 return Some(emit_error(
5737 "--type-aware-require requires --type-aware",
5738 2,
5739 output,
5740 ));
5741 }
5742 if args.trace_opts.symbol_impact.is_some() && args.type_aware != Some(true) {
5743 return Some(emit_error(
5744 "--symbol-impact requires --type-aware",
5745 2,
5746 output,
5747 ));
5748 }
5749 let focused_output = args.trace_opts.trace_export.is_some()
5750 || args.trace_opts.trace_file.is_some()
5751 || args.trace_opts.trace_dependency.is_some()
5752 || args.trace_opts.impact_closure.is_some()
5753 || args.trace_opts.symbol_impact.is_some();
5754 if focused_output
5755 && !matches!(
5756 output,
5757 fallow_config::OutputFormat::Human | fallow_config::OutputFormat::Json
5758 )
5759 {
5760 return Some(emit_error(
5761 "focused trace and impact queries support human and JSON output",
5762 2,
5763 output,
5764 ));
5765 }
5766 if args.type_aware == Some(true)
5767 && !matches!(
5768 output,
5769 fallow_config::OutputFormat::Human
5770 | fallow_config::OutputFormat::Json
5771 | fallow_config::OutputFormat::Sarif
5772 | fallow_config::OutputFormat::Compact
5773 | fallow_config::OutputFormat::Markdown
5774 | fallow_config::OutputFormat::CodeClimate
5775 | fallow_config::OutputFormat::PrCommentGithub
5776 | fallow_config::OutputFormat::PrCommentGitlab
5777 | fallow_config::OutputFormat::ReviewGithub
5778 | fallow_config::OutputFormat::ReviewGitlab
5779 )
5780 {
5781 return Some(emit_error(
5782 "--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",
5783 2,
5784 output,
5785 ));
5786 }
5787 None
5788}
5789
5790fn resolve_ignore_imports(ignore_imports: bool, no_ignore_imports: bool) -> Option<bool> {
5796 if no_ignore_imports {
5797 Some(false)
5798 } else if ignore_imports {
5799 Some(true)
5800 } else {
5801 None
5802 }
5803}
5804
5805struct DupesDispatchArgs {
5806 mode: Option<DupesMode>,
5807 near: bool,
5808 min_tokens: Option<usize>,
5809 min_lines: Option<usize>,
5810 min_occurrences: Option<usize>,
5811 threshold: Option<f64>,
5812 skip_local: bool,
5813 cross_language: bool,
5814 ignore_imports: bool,
5815 no_ignore_imports: bool,
5816 top: Option<usize>,
5817 no_fragments: bool,
5818 trace: Option<String>,
5819 scope: Option<std::path::PathBuf>,
5820}
5821
5822fn dispatch_dupes(dispatch: &DispatchContext<'_>, args: &DupesDispatchArgs) -> ExitCode {
5823 let cli = dispatch.cli;
5824 let (output, quiet, _fail_on_issues) =
5825 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
5826 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::Dupes) {
5827 Ok(production) => production,
5828 Err(code) => return code,
5829 };
5830 dupes::run_dupes(&DupesOptions {
5831 root: dispatch.root,
5832 config_path: &cli.config,
5833 output,
5834 json_style: dispatch.json_style,
5835 no_cache: cli.no_cache,
5836 threads: dispatch.threads,
5837 quiet,
5838 allow_remote_extends: cli.allow_remote_extends,
5839 mode: args.mode,
5840 near: args.near,
5841 min_tokens: args.min_tokens,
5842 min_lines: args.min_lines,
5843 min_occurrences: args.min_occurrences,
5844 threshold: args.threshold,
5845 skip_local: args.skip_local,
5846 cross_language: args.cross_language,
5847 ignore_imports: resolve_ignore_imports(args.ignore_imports, args.no_ignore_imports),
5848 top: args.top,
5849 baseline_path: cli.baseline.as_deref(),
5850 baseline_flag: "--baseline",
5851 save_baseline_path: cli.save_baseline.as_deref(),
5852 fail_on_stale_baseline: cli.fail_on_stale_baseline,
5853 production,
5854 production_override: Some(production),
5855 trace: args.trace.as_deref(),
5856 changed_since: cli.changed_since.as_deref(),
5857 diff_index: None,
5858 use_shared_diff_index: true,
5859 changed_files: None,
5860 workspace: cli.workspace.as_deref(),
5861 changed_workspaces: cli.changed_workspaces.as_deref(),
5862 explain: cli.explain,
5863 explain_skipped: cli.explain_skipped,
5864 summary: cli.summary,
5865 group_by: cli.group_by,
5866 performance: cli.performance,
5867 include_fragments: !args.no_fragments,
5868 scope: args.scope.clone(),
5869 })
5870}
5871
5872struct AuditDispatchArgs {
5873 production_dead_code: bool,
5874 production_health: bool,
5875 production_dupes: bool,
5876 dead_code_baseline: Option<PathBuf>,
5877 health_baseline: Option<PathBuf>,
5878 dupes_baseline: Option<PathBuf>,
5879 max_crap: Option<f64>,
5880 coverage: Option<PathBuf>,
5881 coverage_root: Option<PathBuf>,
5882 no_css: bool,
5883 css_deep: bool,
5884 no_css_deep: bool,
5885 gate: Option<AuditGateArg>,
5886 runtime_coverage: Option<PathBuf>,
5887 min_invocations_hot: u64,
5888 gate_marker: Option<String>,
5889 brief: bool,
5890 max_decisions: usize,
5891 walkthrough_guide: bool,
5893 walkthrough_file: Option<PathBuf>,
5896 walkthrough: bool,
5898 mark_viewed: Vec<PathBuf>,
5900 show_cleared: bool,
5902 show_deprioritized: bool,
5904 scope: Option<PathBuf>,
5905}
5906
5907struct ResolvedAuditInputs {
5908 audit_cfg: fallow_config::AuditConfig,
5909 cache_dir: PathBuf,
5910 production: ProductionModes,
5911 dead_code_baseline: Option<PathBuf>,
5912 health_baseline: Option<PathBuf>,
5913 dupes_baseline: Option<PathBuf>,
5914 coverage: Option<PathBuf>,
5918 coverage_root: Option<PathBuf>,
5919}
5920
5921fn dispatch_audit(dispatch: &DispatchContext<'_>, args: &AuditDispatchArgs) -> ExitCode {
5922 let cli = dispatch.cli;
5923 let output = dispatch.output;
5924
5925 if cli.baseline.is_some() || cli.save_baseline.is_some() {
5926 return emit_error(
5927 "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>`)",
5928 2,
5929 output,
5930 );
5931 }
5932
5933 let inputs = match resolve_audit_inputs(dispatch, args) {
5934 Ok(inputs) => inputs,
5935 Err(code) => return code,
5936 };
5937
5938 run_resolved_audit(dispatch, args, &inputs)
5939}
5940
5941fn resolve_audit_inputs(
5942 dispatch: &DispatchContext<'_>,
5943 args: &AuditDispatchArgs,
5944) -> Result<ResolvedAuditInputs, ExitCode> {
5945 let cli = dispatch.cli;
5946 let root = dispatch.root;
5947 let output = dispatch.output;
5948 let config = load_config(
5949 root,
5950 &cli.config,
5951 LoadConfigArgs {
5952 output,
5953 no_cache: cli.no_cache,
5954 threads: dispatch.threads,
5955 production: cli.production,
5956 quiet: dispatch.quiet,
5957 allow_remote_extends: cli.allow_remote_extends,
5958 },
5959 )?;
5960 let cache_dir = config.cache_dir.clone();
5961 let audit_cfg = config.audit;
5962 let production = resolve_production_modes(
5963 cli,
5964 root,
5965 output,
5966 args.production_dead_code,
5967 args.production_health,
5968 args.production_dupes,
5969 )?;
5970 let resolved_dead_code_baseline = resolve_audit_baseline_path(
5971 root,
5972 args.dead_code_baseline.as_deref(),
5973 audit_cfg.dead_code_baseline.as_deref(),
5974 );
5975 let resolved_health_baseline = resolve_audit_baseline_path(
5976 root,
5977 args.health_baseline.as_deref(),
5978 audit_cfg.health_baseline.as_deref(),
5979 );
5980 let resolved_dupes_baseline = resolve_audit_baseline_path(
5981 root,
5982 args.dupes_baseline.as_deref(),
5983 audit_cfg.dupes_baseline.as_deref(),
5984 );
5985 let coverage_inputs = resolve_coverage_inputs(
5986 args.coverage.as_deref(),
5987 args.coverage_root.as_deref(),
5988 output,
5989 || Ok(config.health),
5990 )?;
5991
5992 Ok(ResolvedAuditInputs {
5993 audit_cfg,
5994 cache_dir,
5995 production,
5996 dead_code_baseline: resolved_dead_code_baseline,
5997 health_baseline: resolved_health_baseline,
5998 dupes_baseline: resolved_dupes_baseline,
5999 coverage: coverage_inputs.coverage,
6000 coverage_root: coverage_inputs.coverage_root,
6001 })
6002}
6003
6004fn audit_css_enabled(config: &fallow_config::AuditConfig, args: &AuditDispatchArgs) -> bool {
6005 !args.no_css && config.css.unwrap_or(true)
6006}
6007
6008fn audit_css_deep_enabled(config: &fallow_config::AuditConfig, args: &AuditDispatchArgs) -> bool {
6009 audit_css_enabled(config, args)
6010 && !args.no_css_deep
6011 && (args.css_deep || config.css_deep.unwrap_or(true))
6012}
6013
6014fn run_resolved_audit(
6015 dispatch: &DispatchContext<'_>,
6016 args: &AuditDispatchArgs,
6017 inputs: &ResolvedAuditInputs,
6018) -> ExitCode {
6019 let cli = dispatch.cli;
6020 audit::run_audit_with_type_aware(
6021 &audit::AuditOptions {
6022 root: dispatch.root,
6023 config_path: &cli.config,
6024 cache_dir: &inputs.cache_dir,
6025 output: dispatch.output,
6026 json_style: dispatch.json_style,
6027 no_cache: cli.no_cache,
6028 threads: dispatch.threads,
6029 quiet: dispatch.quiet,
6030 allow_remote_extends: cli.allow_remote_extends,
6031 changed_since: cli.changed_since.as_deref(),
6032 production: cli.production,
6033 production_dead_code: Some(inputs.production.dead_code),
6034 production_health: Some(inputs.production.health),
6035 production_dupes: Some(inputs.production.dupes),
6036 workspace: cli.workspace.as_deref(),
6037 changed_workspaces: cli.changed_workspaces.as_deref(),
6038 explain: cli.explain,
6039 explain_skipped: cli.explain_skipped,
6040 performance: cli.performance,
6041 group_by: cli.group_by,
6042 dead_code_baseline: inputs.dead_code_baseline.as_deref(),
6043 health_baseline: inputs.health_baseline.as_deref(),
6044 dupes_baseline: inputs.dupes_baseline.as_deref(),
6045 health_baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
6046 fail_on_stale_baseline: cli.fail_on_stale_baseline,
6047 max_crap: args.max_crap,
6048 coverage: inputs.coverage.as_deref(),
6049 coverage_root: inputs.coverage_root.as_deref(),
6050 gate: args.gate.map_or(inputs.audit_cfg.gate, Into::into),
6051 include_entry_exports: cli.include_entry_exports,
6052 fail_on_parse_error: cli.fail_on_parse_error,
6053 css: audit_css_enabled(&inputs.audit_cfg, args),
6057 css_deep: audit_css_deep_enabled(&inputs.audit_cfg, args),
6058 runtime_coverage: args.runtime_coverage.as_deref(),
6059 min_invocations_hot: args.min_invocations_hot,
6060 brief: args.brief,
6061 max_decisions: args.max_decisions,
6062 walkthrough_guide: args.walkthrough_guide,
6063 walkthrough: args.walkthrough,
6064 mark_viewed: &args.mark_viewed,
6065 show_cleared: args.show_cleared,
6066 walkthrough_file: args.walkthrough_file.as_deref(),
6067 show_deprioritized: args.show_deprioritized,
6068 scope: args.scope.clone(),
6069 },
6070 args.gate_marker.as_deref(),
6071 audit::AuditTypeAwareOptions {
6072 enabled: cli.type_aware_override(),
6073 config_default: inputs.audit_cfg.type_aware,
6074 projects: &cli.type_aware_project,
6075 require: cli.type_aware_require.map(Into::into),
6076 },
6077 )
6078}
6079
6080fn dispatch_decision_surface(dispatch: &DispatchContext<'_>, max_decisions: usize) -> ExitCode {
6084 let args = decision_surface_audit_args(max_decisions);
6085 let inputs = match resolve_audit_inputs(dispatch, &args) {
6086 Ok(inputs) => inputs,
6087 Err(code) => return code,
6088 };
6089 if dispatch.cli.fail_on_stale_baseline {
6093 for path in [
6094 &inputs.dead_code_baseline,
6095 &inputs.health_baseline,
6096 &inputs.dupes_baseline,
6097 ] {
6098 baseline_gate::note_stood_down(
6099 path.as_deref(),
6100 true,
6101 "decision-surface renders a brief without exit gates",
6102 );
6103 }
6104 }
6105 audit::run_decision_surface(&decision_surface_audit_options(
6106 dispatch,
6107 &inputs,
6108 max_decisions,
6109 ))
6110}
6111
6112fn decision_surface_audit_args(max_decisions: usize) -> AuditDispatchArgs {
6113 AuditDispatchArgs {
6114 production_dead_code: false,
6115 production_health: false,
6116 production_dupes: false,
6117 dead_code_baseline: None,
6118 health_baseline: None,
6119 dupes_baseline: None,
6120 max_crap: None,
6121 coverage: None,
6122 coverage_root: None,
6123 no_css: true,
6124 css_deep: false,
6125 no_css_deep: false,
6126 gate: None,
6127 runtime_coverage: None,
6128 min_invocations_hot: 0,
6129 gate_marker: None,
6130 brief: true,
6131 max_decisions,
6132 walkthrough_guide: false,
6133 walkthrough_file: None,
6134 walkthrough: false,
6135 mark_viewed: Vec::new(),
6136 show_cleared: false,
6137 show_deprioritized: false,
6138 scope: None,
6139 }
6140}
6141
6142fn decision_surface_audit_options<'a>(
6143 dispatch: &'a DispatchContext<'a>,
6144 inputs: &'a ResolvedAuditInputs,
6145 max_decisions: usize,
6146) -> audit::AuditOptions<'a> {
6147 let cli = dispatch.cli;
6148 audit::AuditOptions {
6149 root: dispatch.root,
6150 config_path: &cli.config,
6151 cache_dir: &inputs.cache_dir,
6152 output: dispatch.output,
6153 json_style: dispatch.json_style,
6154 no_cache: cli.no_cache,
6155 threads: dispatch.threads,
6156 quiet: dispatch.quiet,
6157 allow_remote_extends: cli.allow_remote_extends,
6158 changed_since: cli.changed_since.as_deref(),
6159 production: cli.production,
6160 production_dead_code: Some(inputs.production.dead_code),
6161 production_health: Some(inputs.production.health),
6162 production_dupes: Some(inputs.production.dupes),
6163 workspace: cli.workspace.as_deref(),
6164 changed_workspaces: cli.changed_workspaces.as_deref(),
6165 explain: cli.explain,
6166 explain_skipped: cli.explain_skipped,
6167 performance: cli.performance,
6168 group_by: cli.group_by,
6169 dead_code_baseline: inputs.dead_code_baseline.as_deref(),
6170 health_baseline: inputs.health_baseline.as_deref(),
6171 dupes_baseline: inputs.dupes_baseline.as_deref(),
6172 health_baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
6173 fail_on_stale_baseline: false,
6177 max_crap: None,
6178 coverage: None,
6179 coverage_root: None,
6180 gate: inputs.audit_cfg.gate,
6181 include_entry_exports: cli.include_entry_exports,
6182 fail_on_parse_error: false,
6183 css: false,
6185 css_deep: false,
6186 runtime_coverage: None,
6187 min_invocations_hot: 0,
6188 brief: true,
6189 max_decisions,
6190 walkthrough_guide: false,
6191 walkthrough: false,
6192 mark_viewed: &[],
6193 show_cleared: false,
6194 walkthrough_file: None,
6195 show_deprioritized: false,
6196 scope: None,
6197 }
6198}
6199
6200struct HealthDispatchArgs<'a> {
6201 max_cyclomatic: Option<u16>,
6202 max_cognitive: Option<u16>,
6203 max_crap: Option<f64>,
6204 top: Option<usize>,
6205 sort: health::SortBy,
6206 complexity: bool,
6207 complexity_breakdown: bool,
6208 file_scores: bool,
6209 coverage_gaps: bool,
6210 hotspots: bool,
6211 ownership: bool,
6212 ownership_emails: Option<fallow_config::EmailMode>,
6213 targets: bool,
6214 type_coupling: bool,
6215 css: bool,
6216 effort: Option<EffortFilter>,
6217 score: bool,
6218 min_score: Option<f64>,
6219 min_severity: Option<fallow_output::FindingSeverity>,
6220 report_only: bool,
6221 fail_on_stale_baseline: bool,
6222 fail_on_parse_error: bool,
6223 since: Option<&'a str>,
6224 min_commits: Option<u32>,
6225 save_snapshot: Option<&'a Option<String>>,
6226 trend: bool,
6227 coverage: Option<&'a std::path::Path>,
6228 coverage_root: Option<&'a std::path::Path>,
6229 runtime_coverage: Option<&'a std::path::Path>,
6230 min_invocations_hot: u64,
6231 min_observation_volume: Option<u32>,
6232 low_traffic_threshold: Option<f64>,
6233 scope: Option<std::path::PathBuf>,
6234}
6235
6236type ResolvedHealthCoverageInputs = fallow_api::CoverageInputs;
6237
6238fn resolve_coverage_inputs(
6249 cli_coverage: Option<&std::path::Path>,
6250 cli_coverage_root: Option<&std::path::Path>,
6251 output: fallow_config::OutputFormat,
6252 config_health: impl FnOnce() -> Result<fallow_config::HealthConfig, ExitCode>,
6253) -> Result<ResolvedHealthCoverageInputs, ExitCode> {
6254 let explicit = fallow_api::CoverageInputs {
6255 coverage: cli_coverage.map(std::path::Path::to_path_buf),
6256 coverage_root: cli_coverage_root.map(std::path::Path::to_path_buf),
6257 };
6258 let env = fallow_api::CoverageInputs {
6259 coverage: path_from_env("FALLOW_COVERAGE"),
6260 coverage_root: path_from_env("FALLOW_COVERAGE_ROOT"),
6261 };
6262 let config_health = if fallow_api::CoverageInputs::needs_config_layer(&explicit, &env) {
6263 Some(config_health()?)
6264 } else {
6265 None
6266 };
6267
6268 fallow_api::resolve_coverage_inputs(explicit, env, config_health.as_ref())
6269 .map_err(|err| emit_error(&err.to_string(), 2, output))
6270}
6271
6272fn resolve_health_coverage_inputs(
6275 dispatch: &DispatchContext<'_>,
6276 cli_coverage: Option<&std::path::Path>,
6277 cli_coverage_root: Option<&std::path::Path>,
6278) -> Result<ResolvedHealthCoverageInputs, ExitCode> {
6279 resolve_coverage_inputs(cli_coverage, cli_coverage_root, dispatch.output, || {
6280 Ok(load_config(
6281 dispatch.root,
6282 &dispatch.cli.config,
6283 LoadConfigArgs {
6284 output: dispatch.output,
6285 no_cache: dispatch.cli.no_cache,
6286 threads: dispatch.threads,
6287 production: dispatch.cli.production,
6288 quiet: dispatch.quiet,
6289 allow_remote_extends: dispatch.cli.allow_remote_extends,
6290 },
6291 )?
6292 .health)
6293 })
6294}
6295
6296fn path_from_env(name: &str) -> Option<PathBuf> {
6297 std::env::var_os(name)
6298 .filter(|value| !value.is_empty())
6299 .map(PathBuf::from)
6300}
6301
6302fn validate_health_report_only_gate(
6303 report_only: bool,
6304 min_score: Option<f64>,
6305 min_severity: Option<fallow_output::FindingSeverity>,
6306 output: fallow_config::OutputFormat,
6307) -> Result<(), ExitCode> {
6308 if report_only && (min_score.is_some() || min_severity.is_some()) {
6309 return Err(emit_error(
6310 "--report-only cannot be combined with --min-score or --min-severity. \
6311 --report-only always exits 0; drop it to gate on score/severity, or \
6312 drop the gate flags to stay advisory.",
6313 2,
6314 output,
6315 ));
6316 }
6317
6318 Ok(())
6319}
6320
6321fn resolve_runtime_coverage_options(
6322 runtime_coverage: Option<&std::path::Path>,
6323 min_invocations_hot: u64,
6324 min_observation_volume: Option<u32>,
6325 low_traffic_threshold: Option<f64>,
6326 output: fallow_config::OutputFormat,
6327) -> Result<Option<fallow_engine::health::RuntimeCoverageOptions>, ExitCode> {
6328 let Some(path) = runtime_coverage else {
6329 return Ok(None);
6330 };
6331
6332 health::coverage::prepare_options(
6333 path,
6334 min_invocations_hot,
6335 min_observation_volume,
6336 low_traffic_threshold,
6337 output,
6338 )
6339 .map(Some)
6340}
6341
6342fn dispatch_health(dispatch: &DispatchContext<'_>, args: &HealthDispatchArgs<'_>) -> ExitCode {
6343 let cli = dispatch.cli;
6344 let root = dispatch.root;
6345 let (output, _quiet, _fail_on_issues) =
6346 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
6347 if let Err(code) = validate_health_report_only_gate(
6348 args.report_only,
6349 args.min_score,
6350 args.min_severity,
6351 output,
6352 ) {
6353 return code;
6354 }
6355 let runtime_coverage = match resolve_runtime_coverage_options(
6356 args.runtime_coverage,
6357 args.min_invocations_hot,
6358 args.min_observation_volume,
6359 args.low_traffic_threshold,
6360 output,
6361 ) {
6362 Ok(options) => options,
6363 Err(code) => return code,
6364 };
6365 let production = match resolve_production_modes(cli, root, output, false, false, false) {
6366 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::Health),
6367 Err(code) => return code,
6368 };
6369 let coverage_inputs =
6370 match resolve_health_coverage_inputs(dispatch, args.coverage, args.coverage_root) {
6371 Ok(inputs) => inputs,
6372 Err(code) => return code,
6373 };
6374 let run = derive_health_dispatch_run(args, output, &coverage_inputs, runtime_coverage);
6375 run_health_dispatch(dispatch, args, ResolvedHealthDispatch { run, production })
6376}
6377
6378fn derive_health_dispatch_run<'a>(
6379 args: &'a HealthDispatchArgs<'a>,
6380 output: fallow_config::OutputFormat,
6381 coverage_inputs: &'a ResolvedHealthCoverageInputs,
6382 runtime_coverage: Option<fallow_engine::health::RuntimeCoverageOptions>,
6383) -> fallow_engine::health::HealthRunOptions<'a> {
6384 let mut run = fallow_engine::health::derive_health_run_options(
6385 fallow_engine::health::HealthRunOptionsInput {
6386 output,
6387 thresholds: health_threshold_overrides(args),
6388 top: args.top,
6389 sort: args.sort.clone().into(),
6390 complexity: args.complexity,
6391 file_scores: args.file_scores,
6392 coverage_gaps: args.coverage_gaps,
6393 hotspots: args.hotspots,
6394 ownership: args.ownership,
6395 ownership_emails: args.ownership_emails,
6396 targets: args.targets,
6397 css: args.css,
6398 effort: args.effort.map(EffortFilter::to_estimate),
6399 score: args.score,
6400 gates: health_gate_options(args),
6401 snapshot_requested: args.save_snapshot.is_some(),
6402 trend: args.trend,
6403 since: args.since,
6404 min_commits: args.min_commits,
6405 coverage_inputs: health_coverage_inputs(coverage_inputs),
6406 runtime_coverage,
6407 },
6408 );
6409 if args.type_coupling && !run.sections.any_section {
6410 run.sections = fallow_engine::health::DerivedHealthSections {
6411 any_section: true,
6412 complexity: false,
6413 file_scores: false,
6414 coverage_gaps: false,
6415 hotspots: false,
6416 targets: false,
6417 css: false,
6418 score: false,
6419 force_full: false,
6420 score_only_output: false,
6421 };
6422 }
6423 run
6424}
6425
6426fn health_threshold_overrides(
6427 args: &HealthDispatchArgs<'_>,
6428) -> fallow_engine::health::HealthThresholdOverrides {
6429 fallow_engine::health::HealthThresholdOverrides {
6430 max_cyclomatic: args.max_cyclomatic,
6431 max_cognitive: args.max_cognitive,
6432 max_crap: args.max_crap,
6433 }
6434}
6435
6436fn health_gate_options(args: &HealthDispatchArgs<'_>) -> fallow_engine::health::HealthGateOptions {
6437 fallow_engine::health::HealthGateOptions {
6438 min_score: args.min_score,
6439 min_severity: args.min_severity,
6440 report_only: args.report_only,
6441 fail_on_stale_baseline: args.fail_on_stale_baseline,
6442 fail_on_parse_error: args.fail_on_parse_error,
6443 fail_on_issues: false,
6444 }
6445}
6446
6447fn health_coverage_inputs(
6448 coverage_inputs: &ResolvedHealthCoverageInputs,
6449) -> fallow_engine::health::HealthCoverageInputs<'_> {
6450 fallow_engine::health::HealthCoverageInputs {
6451 coverage: coverage_inputs.coverage.as_deref(),
6452 coverage_root: coverage_inputs.coverage_root.as_deref(),
6453 coverage_relocated: false,
6454 }
6455}
6456
6457struct ResolvedHealthDispatch<'a> {
6461 run: fallow_engine::health::HealthRunOptions<'a>,
6462 production: bool,
6463}
6464
6465fn run_health_dispatch(
6468 dispatch: &DispatchContext<'_>,
6469 args: &HealthDispatchArgs<'_>,
6470 resolved: ResolvedHealthDispatch<'_>,
6471) -> ExitCode {
6472 let cli = dispatch.cli;
6473 let (output, quiet, fail_on_issues) =
6474 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
6475 let mut run = resolved.run;
6476 run.gates.fail_on_issues = fail_on_issues;
6477 let sections = run.sections;
6478 let production = resolved.production;
6479 health::run_health(
6480 &HealthOptions {
6481 root: dispatch.root,
6482 config_path: &cli.config,
6483 output,
6484 no_cache: cli.no_cache,
6485 threads: dispatch.threads,
6486 quiet,
6487 thresholds: run.thresholds,
6488 top: run.top,
6489 sort: run.sort,
6490 production,
6491 production_override: Some(production),
6492 allow_remote_extends: cli.allow_remote_extends,
6493 changed_since: cli.changed_since.as_deref(),
6494 diff_index: None,
6495 use_shared_diff_index: true,
6496 workspace: cli.workspace.as_deref(),
6497 changed_workspaces: cli.changed_workspaces.as_deref(),
6498 baseline: cli.baseline.as_deref(),
6499 save_baseline: cli.save_baseline.as_deref(),
6500 baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
6501 baseline_mode_explicit: cli.baseline_mode.is_some(),
6502 complexity: sections.complexity,
6503 file_scores: sections.file_scores,
6504 coverage_gaps: sections.coverage_gaps,
6505 config_activates_coverage_gaps: !sections.any_section,
6506 hotspots: sections.hotspots,
6507 ownership: run.ownership,
6508 ownership_emails: run.ownership_emails,
6509 targets: sections.targets,
6510 css: sections.css,
6511 css_deep: false,
6512 force_full: sections.force_full,
6513 score_only_output: sections.score_only_output,
6514 enforce_coverage_gap_gate: true,
6515 effort: run.effort,
6516 score: sections.score,
6517 gates: run.gates,
6518 since: run.since,
6519 min_commits: run.min_commits,
6520 explain: cli.explain,
6521 summary: cli.summary,
6522 save_snapshot: args
6523 .save_snapshot
6524 .map(|opt| PathBuf::from(opt.as_deref().unwrap_or_default())),
6525 trend: args.trend,
6526 coverage_inputs: run.coverage_inputs,
6527 performance: cli.performance,
6528 runtime_coverage: run.runtime_coverage,
6529 churn_file: cli.churn_file.as_deref(),
6530 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
6531 complexity_breakdown: args.complexity_breakdown,
6532 group_by: cli.group_by.map(Into::into),
6533 scope: args.scope.clone(),
6534 },
6535 dispatch.json_style,
6536 &health::TypeAwareHealthOptions {
6537 enabled: cli.type_aware_override(),
6538 requested: args.type_coupling,
6539 unfiltered: health_type_coupling_is_default_section(args),
6540 projects: &cli.type_aware_project,
6541 require: cli.type_aware_require.map(Into::into),
6542 },
6543 )
6544}
6545
6546fn health_type_coupling_is_default_section(args: &HealthDispatchArgs<'_>) -> bool {
6547 !args.complexity
6548 && !args.file_scores
6549 && !args.coverage_gaps
6550 && !args.hotspots
6551 && !args.ownership
6552 && !args.targets
6553 && !args.css
6554 && !args.score
6555 && args.min_score.is_none()
6556 && args.min_severity.is_none()
6557 && args.runtime_coverage.is_none()
6558}
6559
6560#[cfg(test)]
6561mod tests {
6562 use super::*;
6563
6564 #[test]
6568 fn cli_definition_has_no_flag_collisions() {
6569 use clap::CommandFactory;
6570 Cli::command().debug_assert();
6571 }
6572
6573 #[test]
6574 fn impact_statusline_subcommand_parses() {
6575 use clap::Parser;
6576
6577 let cli = Cli::try_parse_from(["fallow", "impact", "statusline"]).expect("argv parses");
6578 assert!(matches!(
6579 cli.command,
6580 Some(Command::Impact {
6581 subcommand: Some(ImpactCli::Statusline),
6582 ..
6583 })
6584 ));
6585 }
6586
6587 #[test]
6588 fn impact_statusline_bypasses_command_epilogue() {
6589 use clap::Parser;
6590
6591 let statusline =
6592 Cli::try_parse_from(["fallow", "impact", "statusline"]).expect("argv parses");
6593 assert!(is_impact_statusline(&statusline));
6594
6595 let status = Cli::try_parse_from(["fallow", "impact", "status"]).expect("argv parses");
6596 assert!(!is_impact_statusline(&status));
6597
6598 let all_statusline =
6599 Cli::try_parse_from(["fallow", "impact", "--all", "statusline"]).expect("argv parses");
6600 assert!(!is_impact_statusline(&all_statusline));
6601 }
6602
6603 #[test]
6604 fn regression_baseline_help_explains_the_default_destination() {
6605 use clap::CommandFactory;
6606 let help = Cli::command().render_long_help().to_string();
6607
6608 assert!(help.contains("Omit PATH to update regression.baseline"));
6609 assert!(help.contains("discovered fallow config"));
6610 assert!(help.contains("create .fallowrc.json when none exists"));
6611 }
6612
6613 #[test]
6617 fn after_help_lists_every_task_matrix_command() {
6618 for row in crate::task_matrix::TASK_MATRIX {
6619 assert!(
6620 TOP_LEVEL_AFTER_LONG_HELP.contains(row.command),
6621 "root --help cheat sheet is missing task-matrix command '{}'; \
6622 update the top_level_task_cheat_sheet! fragment to match TASK_MATRIX",
6623 row.command
6624 );
6625 }
6626 }
6627
6628 #[test]
6635 fn after_help_lists_every_visible_subcommand() {
6636 use clap::CommandFactory;
6637
6638 for sub in Cli::command().get_subcommands() {
6639 if sub.is_hide_set() {
6640 continue;
6641 }
6642 let name = sub.get_name();
6643 let listed = TOP_LEVEL_AFTER_LONG_HELP
6644 .lines()
6645 .any(|line| line.split_whitespace().next() == Some(name));
6646 assert!(
6647 listed,
6648 "root --help command list is missing subcommand '{name}'; \
6649 add it to a top_level_*_command_groups! section"
6650 );
6651 }
6652 }
6653
6654 #[test]
6658 fn short_help_stays_scannable_with_cheat_sheet_and_pointer() {
6659 use clap::CommandFactory;
6660
6661 let help = Cli::command().render_help().to_string();
6662 let lines = help.lines().count();
6663 assert!(
6664 lines < 90,
6665 "root -h grew to {lines} lines; keep the short surface under 90 \
6666 (curate hide_short_help and the short after-help instead)"
6667 );
6668 assert!(help.contains("When the agent is about to..."));
6669 assert!(help.contains("Run fallow --help for the complete command list."));
6670 }
6671
6672 #[test]
6676 fn high_value_commands_route_to_distinct_workflows() {
6677 use clap::Parser;
6678 use fallow_config::OutputFormat;
6679
6680 let distinct = [
6681 (vec!["fallow", "impact"], telemetry::Workflow::Impact),
6682 (vec!["fallow", "security"], telemetry::Workflow::Security),
6683 (vec!["fallow", "fix"], telemetry::Workflow::Fix),
6684 (
6685 vec!["fallow", "explain", "unused-exports"],
6686 telemetry::Workflow::Explain,
6687 ),
6688 (
6689 vec!["fallow", "watch"],
6690 telemetry::Workflow::CodeQualityReview,
6691 ),
6692 (
6693 vec!["fallow", "list"],
6694 telemetry::Workflow::ProjectInventory,
6695 ),
6696 (
6697 vec!["fallow", "workspaces"],
6698 telemetry::Workflow::ProjectInventory,
6699 ),
6700 (
6701 vec!["fallow", "schema"],
6702 telemetry::Workflow::ProjectInventory,
6703 ),
6704 (vec!["fallow", "init"], telemetry::Workflow::Setup),
6705 (
6706 vec!["fallow", "hooks", "install", "--target", "git"],
6707 telemetry::Workflow::Setup,
6708 ),
6709 (vec!["fallow", "config-schema"], telemetry::Workflow::Setup),
6710 (vec!["fallow", "plugin-schema"], telemetry::Workflow::Setup),
6711 (
6712 vec!["fallow", "rule-pack-schema"],
6713 telemetry::Workflow::Setup,
6714 ),
6715 (vec!["fallow", "config"], telemetry::Workflow::Setup),
6716 (
6717 vec!["fallow", "ci-template", "gitlab"],
6718 telemetry::Workflow::Setup,
6719 ),
6720 (vec!["fallow", "migrate"], telemetry::Workflow::Setup),
6721 (
6722 vec!["fallow", "telemetry", "status"],
6723 telemetry::Workflow::Setup,
6724 ),
6725 (vec!["fallow", "setup-hooks"], telemetry::Workflow::Setup),
6726 (
6727 vec!["fallow", "audit-cache", "remove", "--root", "."],
6728 telemetry::Workflow::Setup,
6729 ),
6730 (
6731 vec!["fallow", "license", "status"],
6732 telemetry::Workflow::License,
6733 ),
6734 ];
6735 for (argv, expected) in distinct {
6736 let cli = Cli::try_parse_from(&argv).expect("argv parses");
6737 assert_eq!(
6738 telemetry_workflow_for_command(cli.command.as_ref(), OutputFormat::Json),
6739 expected,
6740 "{argv:?} should map to {expected:?}"
6741 );
6742 }
6743 }
6744
6745 #[test]
6750 fn version_flag_accepts_lower_v_upper_v_and_long() {
6751 use clap::CommandFactory;
6752 for argv in [["fallow", "-v"], ["fallow", "-V"], ["fallow", "--version"]] {
6753 let err = Cli::command()
6754 .try_get_matches_from(argv)
6755 .expect_err("version flag should short-circuit parsing");
6756 assert_eq!(
6757 err.kind(),
6758 clap::error::ErrorKind::DisplayVersion,
6759 "{argv:?} should trigger the Version action"
6760 );
6761 }
6762 }
6763
6764 #[test]
6769 fn cli_help_text_contains_no_implementation_status_wording() {
6770 use clap::CommandFactory;
6771 let mut root = Cli::command();
6772 let mut violations: Vec<(String, String)> = Vec::new();
6773 visit_help(&mut root, "fallow", &mut violations);
6774 assert!(
6775 violations.is_empty(),
6776 "found implementation-status wording in --help output:\n{}",
6777 violations
6778 .iter()
6779 .map(|(cmd, line)| format!(" {cmd}: {line}"))
6780 .collect::<Vec<_>>()
6781 .join("\n")
6782 );
6783 }
6784
6785 #[test]
6786 fn dependency_override_help_is_package_manager_neutral() {
6787 use clap::CommandFactory;
6788 let help = Cli::command()
6789 .find_subcommand_mut("dead-code")
6790 .expect("dead-code command")
6791 .render_long_help()
6792 .to_string();
6793
6794 assert!(help.contains("Only report unused package-manager dependency overrides"));
6795 assert!(help.contains("Only report misconfigured package-manager dependency overrides"));
6796 assert!(!help.contains("unused pnpm dependency overrides"));
6797 assert!(!help.contains("misconfigured pnpm dependency overrides"));
6798 }
6799
6800 #[test]
6801 fn top_level_help_groups_commands_by_workflow() {
6802 use clap::CommandFactory;
6803 let help = Cli::command().render_long_help().to_string();
6804 let expected_order = [
6805 "Analysis:",
6806 " dead-code",
6807 " dupes",
6808 " health",
6809 " flags",
6810 " security",
6811 " audit",
6812 "Workflow:",
6813 " watch",
6814 " fix",
6815 "Project inspection:",
6816 " list",
6817 " workspaces",
6818 " explain",
6819 " impact",
6820 " viz",
6821 "Setup and configuration:",
6822 " init",
6823 " recommend",
6824 " migrate",
6825 " config",
6826 " config-schema",
6827 " plugin-schema",
6828 " plugin-check",
6829 " rule-pack-schema",
6830 "Automation and CI:",
6831 " ci",
6832 " ci-template",
6833 " hooks",
6834 " setup-hooks",
6835 "Runtime coverage:",
6836 " coverage",
6837 " license",
6838 "Reference:",
6839 " schema",
6840 " help",
6841 "Options:",
6842 ];
6843 let mut cursor = 0;
6844 for needle in expected_order {
6845 let Some(offset) = help[cursor..].find(needle) else {
6846 panic!("top-level help missing `{needle}` after byte {cursor}:\n{help}");
6847 };
6848 cursor += offset + needle.len();
6849 }
6850 }
6851
6852 #[test]
6853 fn security_help_hides_globals_rejected_by_security_validator() {
6854 let help = render_security_help(SecurityHelpTarget::Parent);
6855
6856 for long in SECURITY_UNSUPPORTED_GLOBAL_LONGS {
6857 assert!(
6858 !help_contains_long_flag(&help, long),
6859 "security help must hide unsupported --{long}:\n{help}"
6860 );
6861 }
6862
6863 for long in [
6864 "root",
6865 "config",
6866 "format",
6867 "quiet",
6868 "no-cache",
6869 "threads",
6870 "changed-since",
6871 "diff-file",
6872 "diff-stdin",
6873 "workspace",
6874 "changed-workspaces",
6875 "ci",
6876 "fail-on-issues",
6877 "sarif-file",
6878 "summary",
6879 "output-file",
6880 "max-file-size",
6881 "explain",
6882 "surface",
6883 ] {
6884 assert!(
6885 help_contains_long_flag(&help, long),
6886 "security help must keep supported --{long}:\n{help}"
6887 );
6888 }
6889 }
6890
6891 #[test]
6892 fn security_help_detection_covers_subcommand_and_help_alias_forms() {
6893 assert_eq!(
6894 security_help_target(["security", "--help"]),
6895 Some(SecurityHelpTarget::Parent)
6896 );
6897 assert_eq!(
6898 security_help_target(["security", "-h"]),
6899 Some(SecurityHelpTarget::Parent)
6900 );
6901 assert_eq!(
6902 security_help_target(["--format", "json", "security", "--help"]),
6903 Some(SecurityHelpTarget::Parent)
6904 );
6905 assert_eq!(
6906 security_help_target(["help", "security"]),
6907 Some(SecurityHelpTarget::Parent)
6908 );
6909 assert_eq!(
6910 security_help_target(["security", "survivors", "--help"]),
6911 Some(SecurityHelpTarget::Survivors)
6912 );
6913 assert_eq!(
6914 security_help_target(["security", "survivors", "-h"]),
6915 Some(SecurityHelpTarget::Survivors)
6916 );
6917 assert_eq!(
6918 security_help_target(["help", "security", "survivors"]),
6919 Some(SecurityHelpTarget::Survivors)
6920 );
6921 assert_eq!(
6922 security_help_target(["security", "blind-spots", "--help"]),
6923 Some(SecurityHelpTarget::BlindSpots)
6924 );
6925 assert_eq!(
6926 security_help_target(["help", "security", "blind-spots"]),
6927 Some(SecurityHelpTarget::BlindSpots)
6928 );
6929 assert_eq!(security_help_target(["health", "--help"]), None);
6930 assert_eq!(security_help_target(["help", "health"]), None);
6931 }
6932
6933 #[test]
6934 fn security_unsupported_global_validator_matches_hidden_help_contract() {
6935 for (argv, expected) in [
6936 (vec!["fallow", "security", "--performance"], "--performance"),
6937 (
6938 vec!["fallow", "security", "--baseline", "base.json"],
6939 "--baseline",
6940 ),
6941 (
6942 vec!["fallow", "security", "--fail-on-stale-baseline"],
6943 "--fail-on-stale-baseline",
6944 ),
6945 (
6946 vec!["fallow", "security", "--dupes-mode", "weak"],
6947 "--dupes-mode",
6948 ),
6949 ] {
6950 let cli = Cli::try_parse_from(argv).expect("security global parses before validation");
6951 assert_eq!(unsupported_security_global(&cli), Some(expected));
6952 }
6953
6954 let explain = Cli::try_parse_from(["fallow", "security", "--explain"])
6955 .expect("security --explain parses");
6956 assert_eq!(unsupported_security_global(&explain), None);
6957 }
6958
6959 #[test]
6960 fn programmatic_common_options_track_analysis_affecting_cli_globals() {
6961 use clap::CommandFactory;
6962
6963 let cli_flags: std::collections::BTreeSet<String> = Cli::command()
6964 .get_arguments()
6965 .filter(|arg| arg.is_global_set())
6966 .filter_map(|arg| arg.get_long().map(str::to_owned))
6967 .filter(|name| {
6968 matches!(
6969 name.as_str(),
6970 "root"
6971 | "config"
6972 | "allow-remote-extends"
6973 | "no-cache"
6974 | "threads"
6975 | "changed-since"
6976 | "diff-file"
6977 | "production"
6978 | "workspace"
6979 | "changed-workspaces"
6980 | "explain"
6981 )
6982 })
6983 .collect();
6984 let programmatic_flags: std::collections::BTreeSet<String> =
6985 fallow_api::COMMON_ANALYSIS_OPTION_FLAGS
6986 .iter()
6987 .map(|flag| (*flag).to_owned())
6988 .collect();
6989
6990 assert_eq!(programmatic_flags, cli_flags);
6991 }
6992
6993 #[test]
6994 fn dead_code_registry_filter_flags_are_exposed_by_clap() {
6995 use clap::CommandFactory;
6996
6997 let cli = Cli::command();
6998 let dead_code = cli
6999 .get_subcommands()
7000 .find(|command| command.get_name() == "dead-code")
7001 .expect("dead-code subcommand is registered");
7002 let cli_flags: std::collections::BTreeSet<String> = dead_code
7003 .get_arguments()
7004 .filter_map(|arg| arg.get_long().map(|long| format!("--{long}")))
7005 .collect();
7006
7007 for flag in fallow_types::issue_meta::DEAD_CODE_FILTER_FLAGS.iter() {
7008 assert!(
7009 cli_flags.contains(*flag),
7010 "registry filter flag {flag} is missing from dead-code clap args"
7011 );
7012 }
7013 }
7014
7015 fn help_contains_long_flag(help: &str, long: &str) -> bool {
7016 let flag = format!("--{long}");
7017 help.split(|c: char| c.is_whitespace() || c == ',' || c == '[' || c == ']')
7018 .any(|token| token == flag)
7019 }
7020
7021 fn visit_help(cmd: &mut clap::Command, path: &str, violations: &mut Vec<(String, String)>) {
7022 let help = cmd.render_long_help().to_string();
7023 for line in scan_forbidden(&help) {
7024 violations.push((path.to_owned(), line));
7025 }
7026 let names: Vec<String> = cmd
7027 .get_subcommands()
7028 .map(|sub| sub.get_name().to_owned())
7029 .collect();
7030 for name in names {
7031 if name == "help" {
7032 continue;
7033 }
7034 if let Some(sub) = cmd.find_subcommand_mut(&name) {
7035 let sub_path = format!("{path} {name}");
7036 visit_help(sub, &sub_path, violations);
7037 }
7038 }
7039 }
7040
7041 fn scan_forbidden(s: &str) -> Vec<String> {
7042 let lower = s.to_ascii_lowercase();
7043 let mut out = Vec::new();
7044 for word in ["stub", "placeholder"] {
7045 if let Some(idx) = find_whole_word(&lower, word) {
7046 out.push(extract_line(s, idx));
7047 }
7048 }
7049 if let Some(idx) = lower.find("not yet") {
7050 out.push(extract_line(s, idx));
7051 }
7052 out
7053 }
7054
7055 fn find_whole_word(haystack: &str, word: &str) -> Option<usize> {
7056 let bytes = haystack.as_bytes();
7057 let mut start = 0;
7058 while let Some(rel) = haystack[start..].find(word) {
7059 let abs = start + rel;
7060 let before_ok = abs == 0 || !bytes[abs - 1].is_ascii_alphanumeric();
7061 let after_idx = abs + word.len();
7062 let after_ok = after_idx >= bytes.len() || !bytes[after_idx].is_ascii_alphanumeric();
7063 if before_ok && after_ok {
7064 return Some(abs);
7065 }
7066 start = abs + word.len();
7067 }
7068 None
7069 }
7070
7071 fn extract_line(s: &str, byte_idx: usize) -> String {
7072 let line_start = s[..byte_idx].rfind('\n').map_or(0, |i| i + 1);
7073 let line_end = s[byte_idx..].find('\n').map_or(s.len(), |i| byte_idx + i);
7074 s[line_start..line_end].trim().to_owned()
7075 }
7076
7077 #[test]
7078 fn emit_error_returns_given_exit_code() {
7079 let code = emit_error("test error", 2, fallow_config::OutputFormat::Human);
7080 assert_eq!(code, ExitCode::from(2));
7081 }
7082
7083 fn telemetry_run_for_mode(mode: telemetry::AnalysisMode) -> TelemetryRun {
7084 TelemetryRun {
7085 workflow: telemetry::Workflow::Health,
7086 output: fallow_config::OutputFormat::Json,
7087 quiet: true,
7088 start: std::time::Instant::now(),
7089 context: telemetry::WorkflowContext {
7090 run_scope: telemetry::RunScope::FullProject,
7091 config_shape: telemetry::ConfigShape::Default,
7092 output_destination: telemetry::OutputDestination::Stdout,
7093 analysis_mode: mode,
7094 },
7095 }
7096 }
7097
7098 #[test]
7099 fn fallback_failure_reason_skips_success_and_findings() {
7100 let run = telemetry_run_for_mode(telemetry::AnalysisMode::Static);
7101
7102 assert_eq!(fallback_failure_reason_for(&run, ExitCode::SUCCESS), None);
7103 assert_eq!(fallback_failure_reason_for(&run, ExitCode::from(1)), None);
7104 }
7105
7106 #[test]
7107 fn fallback_failure_reason_classifies_network_auth_and_analysis() {
7108 let static_run = telemetry_run_for_mode(telemetry::AnalysisMode::Static);
7109 let cloud_run = telemetry_run_for_mode(telemetry::AnalysisMode::ProductionCoverage);
7110
7111 assert_eq!(
7112 fallback_failure_reason_for(&static_run, ExitCode::from(api::NETWORK_EXIT_CODE)),
7113 Some(telemetry::FailureReason::Network),
7114 );
7115 assert_eq!(
7116 fallback_failure_reason_for(&static_run, ExitCode::from(12)),
7117 Some(telemetry::FailureReason::Auth),
7118 );
7119 assert_eq!(
7120 fallback_failure_reason_for(&cloud_run, ExitCode::from(3)),
7121 Some(telemetry::FailureReason::Auth),
7122 );
7123 assert_eq!(
7124 fallback_failure_reason_for(&static_run, ExitCode::from(2)),
7125 Some(telemetry::FailureReason::Analysis),
7126 );
7127 }
7128
7129 #[test]
7130 fn bare_coverage_flags_parse_without_subcommand() {
7131 let cli = Cli::try_parse_from([
7132 "fallow",
7133 "--coverage",
7134 "coverage/coverage-final.json",
7135 "--coverage-root",
7136 "/ci/workspace",
7137 ])
7138 .expect("bare combined coverage flags should parse");
7139 assert!(cli.command.is_none());
7140 assert_eq!(
7141 cli.coverage.as_deref(),
7142 Some(std::path::Path::new("coverage/coverage-final.json"))
7143 );
7144 assert_eq!(
7145 cli.coverage_root.as_deref(),
7146 Some(std::path::Path::new("/ci/workspace"))
7147 );
7148 }
7149
7150 #[test]
7151 fn bare_coverage_before_subcommand_is_detectable() {
7152 let cli = Cli::try_parse_from([
7153 "fallow",
7154 "--coverage",
7155 "coverage/coverage-final.json",
7156 "dead-code",
7157 ])
7158 .expect("clap should parse pre-subcommand bare coverage for custom rejection");
7159 assert!(cli.command.is_some());
7160 assert!(cli_has_bare_coverage_input(&cli));
7161 let message = bare_coverage_subcommand_error_message();
7162 assert!(message.contains("bare combined-mode flags"));
7163 assert!(message.contains("fallow health --coverage <coverage-final.json>"));
7164 }
7165
7166 #[test]
7167 fn bare_combined_baseline_before_subcommand_is_detectable() {
7168 for flag in ["--dupes-baseline", "--health-baseline"] {
7169 let cli = Cli::try_parse_from(["fallow", flag, "x.json", "dead-code"])
7170 .expect("clap should parse a pre-subcommand combined baseline");
7171 assert!(cli.command.is_some());
7172 assert_eq!(cli_bare_combined_baseline_flag(&cli), Some(flag));
7173 let message = bare_combined_baseline_subcommand_error_message(flag);
7174 assert!(message.contains(flag));
7175 assert!(message.contains("omit the subcommand"));
7176 }
7177 let bare = Cli::try_parse_from(["fallow", "--dupes-baseline", "x.json"])
7178 .expect("bare combined baseline should parse");
7179 assert!(bare.command.is_none());
7180 }
7181
7182 #[test]
7183 fn subcommand_coverage_flag_keeps_regular_clap_error() {
7184 let Err(err) = Cli::try_parse_from(["fallow", "dead-code", "--coverage"]) else {
7185 panic!("dead-code --coverage should fail to parse");
7186 };
7187 assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
7188 }
7189
7190 #[test]
7191 fn type_aware_flags_parse_for_semantic_analysis() {
7192 let cli = Cli::try_parse_from([
7193 "fallow",
7194 "dead-code",
7195 "--unused-class-members",
7196 "--type-aware",
7197 "--type-aware-project",
7198 "tsconfig.json",
7199 "--type-aware-project",
7200 "packages/web/tsconfig.json",
7201 ])
7202 .expect("type-aware flag should parse");
7203 assert!(cli.type_aware);
7204 assert_eq!(
7205 cli.type_aware_project,
7206 [
7207 PathBuf::from("tsconfig.json"),
7208 PathBuf::from("packages/web/tsconfig.json")
7209 ]
7210 );
7211 let Some(Command::Check {
7212 unused_class_members,
7213 ..
7214 }) = cli.command
7215 else {
7216 panic!("dead-code should parse as the check command");
7217 };
7218 assert!(unused_class_members);
7219 }
7220
7221 #[test]
7222 fn no_type_aware_conflicts_with_type_aware() {
7223 let Err(err) = Cli::try_parse_from(["fallow", "audit", "--type-aware", "--no-type-aware"])
7224 else {
7225 panic!("--no-type-aware must conflict with --type-aware");
7226 };
7227 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
7228 }
7229
7230 #[test]
7231 fn no_type_aware_forces_semantic_analysis_off() {
7232 let cli = Cli::try_parse_from(["fallow", "audit", "--no-type-aware"])
7233 .expect("--no-type-aware should parse on audit");
7234 assert_eq!(cli.type_aware_override(), Some(false));
7235
7236 let cli = Cli::try_parse_from(["fallow", "dead-code", "--type-aware"])
7237 .expect("--type-aware should parse");
7238 assert_eq!(cli.type_aware_override(), Some(true));
7239
7240 let cli = Cli::try_parse_from(["fallow", "dead-code"]).expect("bare command should parse");
7241 assert_eq!(cli.type_aware_override(), None);
7242 }
7243
7244 #[test]
7245 fn type_aware_status_output_hides_host_paths() {
7246 let root = Path::new("/private/work/project");
7247 let output = type_aware_status_output(
7248 root,
7249 fallow_api::TypeAwareStatus {
7250 available: false,
7251 discovery_source: Some("environment-override"),
7252 companion_path: Some(PathBuf::from("/private/tools/fallow-type-aware")),
7253 package_version: None,
7254 protocol_version: 7,
7255 backend_family: None,
7256 backend_version: None,
7257 remediation: Some(
7258 "failed to launch /private/tools/fallow-type-aware from /private/work/project"
7259 .to_string(),
7260 ),
7261 },
7262 );
7263
7264 assert_eq!(
7265 output.schema_version.0,
7266 fallow_output::TYPE_AWARE_STATUS_SCHEMA_VERSION
7267 );
7268 assert_eq!(output.companion_path.as_deref(), Some("fallow-type-aware"));
7269 let remediation = output.remediation.expect("remediation");
7270 assert!(!remediation.contains("/private/"));
7271 assert!(remediation.contains("fallow-type-aware"));
7272 }
7273
7274 #[test]
7275 fn format_parsing_covers_all_variants() {
7276 assert!(matches!(parse_format_arg("json"), Some(Format::Json)));
7277 assert!(matches!(parse_format_arg("JSON"), Some(Format::Json)));
7278 assert!(matches!(parse_format_arg("human"), Some(Format::Human)));
7279 assert!(matches!(parse_format_arg("sarif"), Some(Format::Sarif)));
7280 assert!(matches!(parse_format_arg("compact"), Some(Format::Compact)));
7281 assert!(matches!(
7282 parse_format_arg("markdown"),
7283 Some(Format::Markdown)
7284 ));
7285 assert!(matches!(parse_format_arg("md"), Some(Format::Markdown)));
7286 assert!(matches!(
7287 parse_format_arg("codeclimate"),
7288 Some(Format::CodeClimate)
7289 ));
7290 assert!(matches!(
7291 parse_format_arg("gitlab-codequality"),
7292 Some(Format::CodeClimate)
7293 ));
7294 assert!(matches!(
7295 parse_format_arg("gitlab-code-quality"),
7296 Some(Format::CodeClimate)
7297 ));
7298 assert!(matches!(
7299 parse_format_arg("pr-comment-github"),
7300 Some(Format::PrCommentGithub)
7301 ));
7302 assert!(matches!(
7303 parse_format_arg("pr-comment-gitlab"),
7304 Some(Format::PrCommentGitlab)
7305 ));
7306 assert!(matches!(
7307 parse_format_arg("review-github"),
7308 Some(Format::ReviewGithub)
7309 ));
7310 assert!(matches!(
7311 parse_format_arg("review-gitlab"),
7312 Some(Format::ReviewGitlab)
7313 ));
7314 assert!(matches!(parse_format_arg("badge"), Some(Format::Badge)));
7315 assert!(parse_format_arg("xml").is_none());
7316 assert!(parse_format_arg("").is_none());
7317 }
7318
7319 #[test]
7320 fn quiet_parsing_logic() {
7321 let parse = |s: &str| -> bool { s == "1" || s.eq_ignore_ascii_case("true") };
7322 assert!(parse("1"));
7323 assert!(parse("true"));
7324 assert!(parse("TRUE"));
7325 assert!(parse("True"));
7326 assert!(!parse("0"));
7327 assert!(!parse("false"));
7328 assert!(!parse("yes"));
7329 }
7330
7331 #[test]
7332 fn tracing_filter_defaults_to_warn_without_env() {
7333 assert_eq!(build_tracing_filter(None).to_string(), "warn");
7334 }
7335
7336 #[test]
7337 fn tracing_filter_respects_explicit_env_directives() {
7338 assert_eq!(build_tracing_filter(Some("info")).to_string(), "info");
7339 }
7340
7341 #[test]
7342 fn tracing_filter_treats_empty_env_as_off() {
7343 assert_eq!(build_tracing_filter(Some("")).to_string(), "off");
7344 assert_eq!(build_tracing_filter(Some(" ")).to_string(), "off");
7345 }
7346}