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 rule_pack;
81mod runtime_support;
82mod schema;
83mod scope_path;
84mod security;
85mod security_help;
86mod selector;
87mod setup_hooks;
88mod signal;
89mod similar_code_cli;
90mod similar_code_help;
91mod suppressions;
92mod task_matrix;
93mod telemetry;
94mod trace_chain;
95mod trace_error;
96mod trace_path;
97mod type_aware_degrade;
98mod update_check;
99use fallow_engine::validate;
100use fallow_engine::vital_signs;
101mod cli_telemetry;
102mod viz;
103mod watch;
104
105use check::{CheckOptions, IssueFilters, TraceOptions};
106pub(crate) mod error;
108use cli_agent::{AgentCli, run_agent_command};
109#[cfg(test)]
110use cli_format::parse_format_arg;
111use cli_format::{Format, FormatConfig};
112use cli_hooks::{HooksCli, run_hooks_command};
113use cli_impact::{ImpactCli, ImpactCrossRepoOpts, ImpactSortCli, dispatch_impact};
114use cli_production::{ProductionModes, resolve_production_modes};
115#[cfg(test)]
116use cli_startup::build_tracing_filter;
117use cli_startup::{
118 bare_coverage_subcommand_error_message, cli_has_bare_coverage_input, parse_cli_args,
119 run_pre_dispatch_checks, setup_tracing, validate_inputs,
120};
121#[cfg(test)]
122use cli_telemetry::TelemetryRun;
123#[cfg(test)]
124use cli_telemetry::{fallback_failure_reason_for, telemetry_workflow_for_command};
125use cli_telemetry::{record_run_epilogue, start_telemetry_run};
126use dupes::{DupesMode, DupesOptions};
127use error::emit_error;
128use health::{HealthOptions, SortBy};
129use list::ListOptions;
130pub(crate) use runtime_support::{AnalysisKind, GroupBy};
131pub(crate) use runtime_support::{
132 ConfigLoadOptions, LoadConfigArgs, build_ownership_resolver, load_config,
133 load_config_for_analysis,
134};
135#[cfg(test)]
136use security_help::{SECURITY_UNSUPPORTED_GLOBAL_LONGS, SecurityHelpTarget};
137use security_help::{render_security_help, security_help_target};
138use similar_code_help::{render_similar_code_help, similar_code_help_target};
139
140const DEFAULT_MIN_INVOCATIONS_HOT: u64 = 100;
141
142const TOP_LEVEL_HELP_TEMPLATE: &str =
143 "{about-with-newline}\n{usage-heading} {usage}{after-help}\n\nOptions:\n{options}";
144
145macro_rules! top_level_task_cheat_sheet {
148 () => {
149 "\
150When the agent is about to...
151 delete an \"unused\" export or file fallow dead-code --trace <file>:<export>
152 prove exact TypeScript symbol consumers fallow dead-code --type-aware --symbol-impact <file>:<export-or-class.method>
153 find how one module reaches another fallow trace --path <from> <to>
154 delete an \"unused\" dependency fallow dead-code --trace-dependency <name>
155 commit or open a PR fallow audit --base <ref>
156 read a diff before approving it fallow review --base <ref> --brief
157 prioritize refactoring fallow health --hotspots --targets
158 ask who owns code fallow health --ownership
159 check untested-but-reachable code fallow health --coverage-gaps
160 consolidate duplication fallow dupes --trace dup:<fingerprint>
161 find feature flags fallow flags
162 check architecture rules before editing fallow guard <files>
163 surface security candidates fallow security
164 inspect a target before editing fallow inspect --file <path>
165 understand a finding fallow explain <issue-type>
166 scope a monorepo --workspace <glob> / --changed-workspaces <ref>"
167 };
168}
169
170macro_rules! top_level_core_command_groups {
171 () => {
172 "\
173Analysis:
174 dead-code Analyze unused code, dependency hygiene, and architecture cycles
175 dupes Find copy-paste and structural code duplication
176 health Analyze complexity, maintainability, hotspots, and coverage gaps
177 flags Detect feature flag usage patterns
178 security Surface local security candidates for agent verification (opt-in)
179 similar-code Find semantic implementation overlap for verification (opt-in, local)
180 audit Review changed files for dead code, complexity, duplication, and styling
181
182Workflow:
183 watch Re-run analysis as files change
184 fix Auto-fix safe unused-code findings"
185 };
186}
187
188macro_rules! top_level_extended_command_groups {
189 () => {
190 "\
191Project inspection:
192 list List discovered files, entry points, plugins, boundaries, and workspaces
193 inspect Inspect one file or exported symbol as a bundled evidence query
194 trace Trace a symbol's call chain (best-effort, syntactic)
195 trace-error Resolve a runtime stack trace's frames to project definitions
196 guard Show which architecture rules apply to files before editing
197 decision-surface Surface the structural decisions a change embeds (advisory)
198 workspaces Show monorepo workspace discovery diagnostics
199 explain Explain one issue type without running analysis
200 suppressions List active fallow-ignore suppression markers
201 impact Show what fallow has done for you (opt-in, local-only)
202 viz Generate an interactive HTML map of the codebase
203
204Setup and configuration:
205 doctor Diagnose project readiness without changing anything
206 init Create a fallow config, optionally with a Git hook
207 agent Wire fallow into Claude Code, Codex, or Cursor in one pass
208 audit-cache Maintain reusable audit base-snapshot caches
209 recommend Recommend a project-tailored config for an agent to author
210 migrate Migrate knip, jscpd, or stylelint config to fallow
211 config Show the resolved config and loaded config file
212 config-schema Print the fallow config JSON Schema
213 plugin-schema Print the external plugin JSON Schema
214 plugin-check Dry-run external plugins and report what they seed
215 rule-pack Manage declarative rule packs (policy-as-code)
216 rule-pack-schema Print the rule pack JSON Schema
217 type-aware Inspect the optional TypeScript semantic companion
218
219Automation and CI:
220 ci Build PR/MR feedback envelopes
221 ci-template Print or vendor CI integration templates
222 report Re-render saved JSON as GitHub or CodeClimate output
223 hooks Install or remove fallow-managed Git and agent hooks
224 setup-hooks Deprecated: use `agent install` or `hooks install --target agent`
225
226Runtime coverage:
227 coverage Set up or analyze runtime coverage data
228 license Manage the paid-feature license
229 telemetry Manage opt-in product telemetry
230
231Reference:
232 schema Dump the CLI interface as machine-readable JSON
233 help Print this message or the help of a command"
234 };
235}
236
237const TOP_LEVEL_AFTER_HELP: &str = concat!(
238 top_level_task_cheat_sheet!(),
239 "\n\n",
240 top_level_core_command_groups!(),
241 "\n\nRun fallow --help for the complete command list."
242);
243
244const TOP_LEVEL_AFTER_LONG_HELP: &str = concat!(
245 top_level_task_cheat_sheet!(),
246 "\n\n",
247 top_level_core_command_groups!(),
248 "\n\n",
249 top_level_extended_command_groups!(),
250 "\n\n",
251 "When no command is given, fallow runs dead-code + dupes + health together.\n",
252 "Use --only/--skip to select specific analyses."
253);
254
255#[derive(Parser)]
256#[command(
257 name = "fallow",
258 about = "Codebase analyzer for TypeScript/JavaScript: unused code, circular dependencies, code duplication, complexity hotspots, and architecture boundary violations",
259 version,
260 disable_version_flag = true,
261 help_template = TOP_LEVEL_HELP_TEMPLATE,
262 after_help = TOP_LEVEL_AFTER_HELP,
263 after_long_help = TOP_LEVEL_AFTER_LONG_HELP
264)]
265struct Cli {
266 #[command(subcommand)]
267 command: Option<Command>,
268
269 #[arg(value_name = "PATH")]
272 path: Option<PathBuf>,
273
274 #[arg(
278 short = 'v',
279 visible_short_alias = 'V',
280 long = "version",
281 action = clap::ArgAction::Version
282 )]
283 version: Option<bool>,
284
285 #[arg(short, long, global = true)]
287 root: Option<PathBuf>,
288
289 #[arg(short, long, global = true)]
291 config: Option<PathBuf>,
292
293 #[arg(hide_short_help = true, long, global = true)]
295 allow_remote_extends: bool,
296
297 #[arg(
299 short,
300 long,
301 visible_alias = "output",
302 global = true,
303 default_value = "human"
304 )]
305 format: Format,
306
307 #[arg(hide_short_help = true, long, global = true)]
309 pretty: bool,
310
311 #[arg(short, long, global = true)]
313 quiet: bool,
314
315 #[arg(hide_short_help = true, long, global = true)]
317 no_cache: bool,
318
319 #[arg(hide_short_help = true, long, global = true)]
321 threads: Option<usize>,
322
323 #[arg(long, visible_alias = "base", global = true)]
325 changed_since: Option<String>,
326
327 #[arg(
332 hide_short_help = true,
333 long = "diff-file",
334 value_name = "PATH",
335 global = true
336 )]
337 diff_file: Option<PathBuf>,
338
339 #[arg(hide_short_help = true, long = "diff-stdin", global = true)]
342 diff_stdin: bool,
343
344 #[arg(
351 hide_short_help = true,
352 long = "churn-file",
353 value_name = "PATH",
354 global = true
355 )]
356 churn_file: Option<PathBuf>,
357
358 #[arg(
365 hide_short_help = true,
366 long = "max-file-size",
367 value_name = "MB",
368 global = true
369 )]
370 max_file_size: Option<u32>,
371
372 #[arg(hide_short_help = true, long, global = true)]
374 baseline: Option<PathBuf>,
375
376 #[arg(
391 hide_short_help = true,
392 long = "baseline-mode",
393 value_enum,
394 global = true
395 )]
396 baseline_mode: Option<BaselineModeArg>,
397
398 #[arg(long, global = true, value_name = "RUN_ID", hide = true)]
404 parent_run: Option<String>,
405
406 #[arg(hide_short_help = true, long, global = true)]
408 save_baseline: Option<PathBuf>,
409
410 #[arg(long, global = true)]
413 production: bool,
414
415 #[arg(
419 hide_short_help = true,
420 long = "no-production",
421 global = true,
422 conflicts_with = "production"
423 )]
424 no_production: bool,
425
426 #[arg(hide_short_help = true, long = "production-dead-code")]
428 production_dead_code: bool,
429
430 #[arg(hide_short_help = true, long = "production-health")]
432 production_health: bool,
433
434 #[arg(hide_short_help = true, long = "production-dupes")]
436 production_dupes: bool,
437
438 #[arg(short, long, global = true, value_delimiter = ',')]
442 workspace: Option<Vec<String>>,
443
444 #[arg(long, global = true, value_name = "REF")]
447 changed_workspaces: Option<String>,
448
449 #[arg(hide_short_help = true, long, global = true)]
451 group_by: Option<GroupBy>,
452
453 #[arg(hide_short_help = true, long, global = true)]
455 performance: bool,
456
457 #[arg(hide_short_help = true, long, global = true)]
459 explain: bool,
460
461 #[arg(hide_short_help = true, long, global = true)]
464 explain_skipped: bool,
465
466 #[arg(hide_short_help = true, long, global = true)]
468 summary: bool,
469
470 #[arg(long, global = true)]
472 ci: bool,
473
474 #[arg(hide_short_help = true, long, global = true)]
476 fail_on_issues: bool,
477
478 #[arg(hide_short_help = true, long, global = true, value_name = "PATH")]
480 sarif_file: Option<PathBuf>,
481
482 #[arg(short = 'o', long, global = true, value_name = "PATH")]
486 output_file: Option<PathBuf>,
487
488 #[arg(
498 hide_short_help = true,
499 long = "report-path-prefix",
500 visible_alias = "annotations-path-prefix",
501 global = true,
502 value_name = "PREFIX"
503 )]
504 report_path_prefix: Option<String>,
505
506 #[arg(hide_short_help = true, long, global = true)]
508 fail_on_regression: bool,
509
510 #[arg(hide_short_help = true, long, global = true)]
524 fail_on_stale_baseline: bool,
525
526 #[arg(
528 hide_short_help = true,
529 long,
530 global = true,
531 value_name = "TOLERANCE",
532 default_value = "0"
533 )]
534 tolerance: String,
535
536 #[arg(hide_short_help = true, long, global = true, value_name = "PATH")]
538 regression_baseline: Option<PathBuf>,
539
540 #[expect(
544 clippy::option_option,
545 reason = "clap pattern: None=not passed, Some(None)=flag only (write to config), Some(Some(path))=write to file"
546 )]
547 #[arg(hide_short_help = true, long, global = true, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
548 save_regression_baseline: Option<Option<String>>,
549
550 #[arg(long, value_delimiter = ',')]
552 only: Vec<AnalysisKind>,
553
554 #[arg(long, value_delimiter = ',')]
556 skip: Vec<AnalysisKind>,
557
558 #[arg(hide_short_help = true, long = "dupes-mode", global = true)]
560 dupes_mode: Option<DupesMode>,
561
562 #[arg(hide_short_help = true, long = "dupes-near", global = true)]
564 dupes_near: bool,
565
566 #[arg(hide_short_help = true, long = "dupes-threshold", global = true)]
568 dupes_threshold: Option<f64>,
569
570 #[arg(hide_short_help = true, long = "dupes-min-tokens", global = true)]
572 dupes_min_tokens: Option<usize>,
573
574 #[arg(hide_short_help = true, long = "dupes-min-lines", global = true)]
576 dupes_min_lines: Option<usize>,
577
578 #[arg(hide_short_help = true, long = "dupes-min-occurrences", global = true, value_parser = parse_min_occurrences)]
580 dupes_min_occurrences: Option<usize>,
581
582 #[arg(hide_short_help = true, long = "dupes-skip-local", global = true)]
584 dupes_skip_local: bool,
585
586 #[arg(hide_short_help = true, long = "dupes-cross-language", global = true)]
588 dupes_cross_language: bool,
589
590 #[arg(hide_short_help = true, long = "dupes-ignore-imports", global = true)]
593 dupes_ignore_imports: bool,
594
595 #[arg(
598 hide_short_help = true,
599 long = "dupes-no-ignore-imports",
600 global = true,
601 conflicts_with = "dupes_ignore_imports"
602 )]
603 dupes_no_ignore_imports: bool,
604
605 #[arg(hide_short_help = true, long)]
607 score: bool,
608
609 #[arg(hide_short_help = true, long)]
611 trend: bool,
612
613 #[expect(
616 clippy::option_option,
617 reason = "clap pattern: None=not passed, Some(None)=default path, Some(Some(path))=custom path"
618 )]
619 #[arg(hide_short_help = true, long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
620 save_snapshot: Option<Option<String>>,
621
622 #[arg(hide_short_help = true, long, value_name = "PATH")]
625 coverage: Option<PathBuf>,
626
627 #[arg(hide_short_help = true, long = "coverage-root", value_name = "PATH")]
630 coverage_root: Option<PathBuf>,
631
632 #[arg(hide_short_help = true, long, global = true)]
634 include_entry_exports: bool,
635
636 #[arg(hide_short_help = true, long, global = true)]
639 type_aware: bool,
640
641 #[arg(
644 hide_short_help = true,
645 long,
646 global = true,
647 conflicts_with = "type_aware"
648 )]
649 no_type_aware: bool,
650
651 #[arg(hide_short_help = true, long, global = true, value_name = "PATH", action = clap::ArgAction::Append)]
653 type_aware_project: Vec<PathBuf>,
654
655 #[arg(hide_short_help = true, long, global = true, value_enum)]
657 type_aware_require: Option<TypeAwareRequireArg>,
658}
659
660impl Cli {
661 const fn type_aware_override(&self) -> Option<bool> {
665 if self.no_type_aware {
666 Some(false)
667 } else if self.type_aware {
668 Some(true)
669 } else {
670 None
671 }
672 }
673}
674
675#[derive(Clone, Copy, Subcommand)]
676enum TypeAwareCli {
677 Status,
679}
680
681#[derive(Subcommand)]
682enum Command {
683 #[command(name = "dead-code", alias = "check")]
685 Check {
686 #[arg(long)]
688 unused_files: bool,
689
690 #[arg(long)]
692 unused_exports: bool,
693
694 #[arg(long)]
696 unused_deps: bool,
697
698 #[arg(long)]
700 unused_types: bool,
701
702 #[arg(long)]
704 private_type_leaks: bool,
705
706 #[arg(long)]
708 unused_enum_members: bool,
709
710 #[arg(long)]
712 unused_class_members: bool,
713
714 #[arg(long)]
716 unused_store_members: bool,
717
718 #[arg(long)]
720 unprovided_injects: bool,
721
722 #[arg(long)]
724 unrendered_components: bool,
725
726 #[arg(long)]
728 unused_component_props: bool,
729
730 #[arg(long)]
732 unused_component_emits: bool,
733
734 #[arg(long)]
736 unused_component_inputs: bool,
737
738 #[arg(long)]
740 unused_component_outputs: bool,
741
742 #[arg(long)]
744 unused_svelte_events: bool,
745
746 #[arg(long)]
748 unused_server_actions: bool,
749
750 #[arg(long)]
752 unused_load_data_keys: bool,
753
754 #[arg(long)]
756 unresolved_imports: bool,
757
758 #[arg(long)]
760 unlisted_deps: bool,
761
762 #[arg(long)]
764 duplicate_exports: bool,
765
766 #[arg(long)]
768 circular_deps: bool,
769
770 #[arg(long)]
772 re_export_cycles: bool,
773
774 #[arg(long)]
776 boundary_violations: bool,
777
778 #[arg(long)]
780 policy_violations: bool,
781
782 #[arg(long)]
784 stale_suppressions: bool,
785
786 #[arg(long)]
788 unused_catalog_entries: bool,
789
790 #[arg(long)]
792 empty_catalog_groups: bool,
793
794 #[arg(long)]
796 unresolved_catalog_references: bool,
797
798 #[arg(long)]
800 unused_dependency_overrides: bool,
801
802 #[arg(long)]
804 misconfigured_dependency_overrides: bool,
805
806 #[arg(long)]
808 include_dupes: bool,
809
810 #[arg(long, value_name = "FILE:EXPORT")]
812 trace: Option<String>,
813
814 #[arg(long, value_name = "PATH")]
816 trace_file: Option<String>,
817
818 #[arg(long, value_name = "PACKAGE")]
820 trace_dependency: Option<String>,
821
822 #[arg(long, value_name = "PATH")]
826 impact_closure: Option<String>,
827
828 #[arg(long, value_name = "FILE:EXPORT")]
830 symbol_impact: Option<String>,
831
832 #[arg(long)]
837 top: Option<usize>,
838
839 #[arg(long, value_name = "PATH")]
843 file: Vec<std::path::PathBuf>,
844
845 #[arg(value_name = "PATH")]
848 path: Option<std::path::PathBuf>,
849 },
850
851 Watch {
853 #[arg(long)]
855 no_clear: bool,
856 },
857
858 TypeAware {
860 #[command(subcommand)]
861 subcommand: TypeAwareCli,
862 },
863
864 #[command(override_help = doctor::HELP)]
871 Doctor,
872
873 SimilarCode {
879 #[command(subcommand)]
880 subcommand: Option<similar_code_cli::SimilarCodeSubcommand>,
881 #[arg(long, value_name = "0..1")]
883 threshold: Option<f64>,
884 #[arg(long, value_name = "N")]
886 min_lines: Option<usize>,
887 #[arg(long, value_name = "N")]
889 top: Option<usize>,
890 #[arg(long, value_name = "PATH")]
892 file: Vec<PathBuf>,
893
894 #[arg(value_name = "PATH")]
897 path: Option<PathBuf>,
898 },
899
900 Inspect {
902 #[arg(
904 long,
905 value_name = "PATH",
906 conflicts_with = "symbol",
907 required_unless_present = "symbol"
908 )]
909 file: Option<String>,
910
911 #[arg(long, value_name = "FILE:EXPORT", conflicts_with = "file")]
913 symbol: Option<String>,
914
915 #[arg(long)]
920 symbol_chain: bool,
921
922 #[arg(long)]
925 churn: bool,
926 },
927
928 Trace {
943 #[arg(value_name = "FILE:SYMBOL", required_unless_present = "path")]
946 symbol: Option<String>,
947
948 #[arg(
952 long,
953 num_args = 2,
954 value_names = ["FROM", "TO"],
955 conflicts_with_all = ["symbol", "callers", "callees", "depth"]
956 )]
957 path: Vec<String>,
958
959 #[arg(long)]
962 callers: bool,
963
964 #[arg(long)]
967 callees: bool,
968
969 #[arg(long, value_name = "N")]
972 depth: Option<u32>,
973 },
974
975 #[command(name = "trace-error")]
994 TraceError {
995 #[arg(value_name = "FILE")]
999 trace_file: Option<String>,
1000 },
1001
1002 Fix {
1017 #[arg(long)]
1019 dry_run: bool,
1020
1021 #[arg(long, alias = "force")]
1023 yes: bool,
1024
1025 #[arg(long)]
1032 no_create_config: bool,
1033
1034 #[arg(value_name = "PATH")]
1038 path: Option<PathBuf>,
1039 },
1040
1041 Init {
1050 #[arg(long)]
1052 toml: bool,
1053
1054 #[arg(long, conflicts_with_all = ["toml", "hooks", "branch"])]
1056 agents: bool,
1057
1058 #[arg(long)]
1062 hooks: bool,
1063
1064 #[arg(long, requires = "hooks")]
1066 branch: Option<String>,
1067
1068 #[arg(long, conflicts_with_all = ["toml", "agents", "hooks", "branch"])]
1072 decline: bool,
1073 },
1074
1075 Hooks {
1082 #[command(subcommand)]
1083 subcommand: HooksCli,
1084 },
1085
1086 Agent {
1092 #[command(subcommand)]
1093 subcommand: AgentCli,
1094 },
1095
1096 Ci {
1098 #[command(subcommand)]
1099 subcommand: CiCli,
1100 },
1101
1102 ConfigSchema,
1104
1105 PluginSchema,
1107
1108 PluginCheck,
1110
1111 RulePackSchema,
1113
1114 RulePack {
1116 #[command(subcommand)]
1117 subcommand: RulePackCli,
1118 },
1119
1120 Guard {
1122 #[arg(required = true, num_args = 1..)]
1124 files: Vec<String>,
1125 },
1126
1127 Config {
1145 #[arg(long)]
1147 path: bool,
1148 },
1149
1150 Recommend,
1158
1159 List {
1161 #[arg(long)]
1163 entry_points: bool,
1164
1165 #[arg(long)]
1167 files: bool,
1168
1169 #[arg(long)]
1171 plugins: bool,
1172
1173 #[arg(long)]
1175 boundaries: bool,
1176
1177 #[arg(long)]
1181 workspaces: bool,
1182
1183 #[arg(value_name = "PATH")]
1186 path: Option<PathBuf>,
1187 },
1188
1189 Workspaces,
1195
1196 Dupes {
1198 #[arg(long)]
1201 mode: Option<DupesMode>,
1202
1203 #[arg(long)]
1205 near: bool,
1206
1207 #[arg(long)]
1210 min_tokens: Option<usize>,
1211
1212 #[arg(long)]
1215 min_lines: Option<usize>,
1216
1217 #[arg(long, value_parser = parse_min_occurrences)]
1222 min_occurrences: Option<usize>,
1223
1224 #[arg(long)]
1227 threshold: Option<f64>,
1228
1229 #[arg(long)]
1231 skip_local: bool,
1232
1233 #[arg(long)]
1235 cross_language: bool,
1236
1237 #[arg(long)]
1241 ignore_imports: bool,
1242
1243 #[arg(long, conflicts_with = "ignore_imports")]
1246 no_ignore_imports: bool,
1247
1248 #[arg(long)]
1258 top: Option<usize>,
1259
1260 #[arg(long)]
1264 no_fragments: bool,
1265
1266 #[arg(long, value_name = "FILE:LINE")]
1268 trace: Option<String>,
1269
1270 #[arg(value_name = "PATH")]
1273 path: Option<PathBuf>,
1274 },
1275
1276 Health {
1282 #[arg(long)]
1284 max_cyclomatic: Option<u16>,
1285
1286 #[arg(long)]
1288 max_cognitive: Option<u16>,
1289
1290 #[arg(long)]
1294 max_crap: Option<f64>,
1295
1296 #[arg(long)]
1298 top: Option<usize>,
1299
1300 #[arg(long, default_value = "cyclomatic")]
1302 sort: SortBy,
1303
1304 #[arg(long)]
1307 complexity: bool,
1308
1309 #[arg(long)]
1316 complexity_breakdown: bool,
1317
1318 #[arg(long)]
1323 file_scores: bool,
1324
1325 #[arg(long)]
1328 coverage_gaps: bool,
1329
1330 #[arg(long)]
1333 hotspots: bool,
1334
1335 #[arg(long)]
1339 ownership: bool,
1340
1341 #[arg(long, value_name = "MODE", value_enum)]
1346 ownership_emails: Option<EmailModeArg>,
1347
1348 #[arg(long)]
1351 targets: bool,
1352
1353 #[arg(long)]
1356 type_coupling: bool,
1357
1358 #[arg(long)]
1363 css: bool,
1364
1365 #[arg(long, value_enum)]
1368 effort: Option<EffortFilter>,
1369
1370 #[arg(long)]
1373 score: bool,
1374
1375 #[arg(long, value_name = "N")]
1384 min_score: Option<f64>,
1385
1386 #[arg(long, value_name = "LEVEL", value_enum)]
1390 min_severity: Option<HealthSeverityCli>,
1391
1392 #[arg(long)]
1396 report_only: bool,
1397
1398 #[arg(long, value_name = "DURATION")]
1401 since: Option<String>,
1402
1403 #[arg(long, value_name = "N")]
1405 min_commits: Option<u32>,
1406
1407 #[expect(
1411 clippy::option_option,
1412 reason = "clap pattern: None=not passed, Some(None)=flag only, Some(Some(path))=with value"
1413 )]
1414 #[arg(long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
1415 save_snapshot: Option<Option<String>>,
1416
1417 #[arg(long)]
1421 trend: bool,
1422
1423 #[arg(long, value_name = "PATH")]
1432 coverage: Option<PathBuf>,
1433
1434 #[arg(long, value_name = "PATH")]
1440 coverage_root: Option<PathBuf>,
1441
1442 #[arg(long, value_name = "PATH")]
1446 runtime_coverage: Option<PathBuf>,
1447
1448 #[arg(long, default_value_t = 100)]
1450 min_invocations_hot: u64,
1451
1452 #[arg(long, value_name = "N")]
1458 min_observation_volume: Option<u32>,
1459
1460 #[arg(long, value_name = "RATIO")]
1465 low_traffic_threshold: Option<f64>,
1466
1467 #[arg(value_name = "PATH")]
1470 path: Option<PathBuf>,
1471 },
1472
1473 Flags {
1480 #[arg(long)]
1482 top: Option<usize>,
1483 },
1484
1485 Suppressions {
1495 #[arg(long, value_name = "PATH")]
1497 file: Vec<std::path::PathBuf>,
1498 },
1499
1500 Explain {
1506 #[arg(required = true, num_args = 1.., value_name = "ISSUE_TYPE")]
1508 issue_type: Vec<String>,
1509 },
1510
1511 #[command(visible_alias = "review")]
1536 Audit {
1537 #[arg(long = "production-dead-code")]
1539 production_dead_code: bool,
1540
1541 #[arg(long = "production-health")]
1543 production_health: bool,
1544
1545 #[arg(long = "production-dupes")]
1547 production_dupes: bool,
1548
1549 #[arg(long)]
1552 dead_code_baseline: Option<PathBuf>,
1553
1554 #[arg(long)]
1557 health_baseline: Option<PathBuf>,
1558
1559 #[arg(long)]
1562 dupes_baseline: Option<PathBuf>,
1563
1564 #[arg(long)]
1568 max_crap: Option<f64>,
1569
1570 #[arg(long, value_name = "PATH")]
1574 coverage: Option<PathBuf>,
1575
1576 #[arg(long, value_name = "PATH")]
1580 coverage_root: Option<PathBuf>,
1581
1582 #[arg(long = "no-css")]
1584 no_css: bool,
1585
1586 #[arg(long)]
1590 css_deep: bool,
1591
1592 #[arg(long = "no-css-deep")]
1594 no_css_deep: bool,
1595
1596 #[arg(long, value_enum)]
1602 gate: Option<AuditGateArg>,
1603
1604 #[arg(long, value_name = "PATH")]
1613 runtime_coverage: Option<PathBuf>,
1614
1615 #[arg(long, default_value_t = 100)]
1618 min_invocations_hot: u64,
1619
1620 #[arg(long, value_name = "MARKER", hide = true)]
1625 gate_marker: Option<String>,
1626
1627 #[arg(long)]
1633 brief: bool,
1634
1635 #[arg(
1640 long,
1641 value_name = "N",
1642 default_value_t = audit_decision_surface::DEFAULT_DECISION_CAP
1643 )]
1644 max_decisions: usize,
1645
1646 #[arg(long, conflicts_with_all = ["walkthrough_file", "walkthrough"])]
1654 walkthrough_guide: bool,
1655
1656 #[arg(long, value_name = "PATH")]
1666 walkthrough_file: Option<PathBuf>,
1667
1668 #[arg(long, conflicts_with_all = ["walkthrough_guide", "walkthrough_file"])]
1674 walkthrough: bool,
1675
1676 #[arg(long, value_name = "PATH")]
1682 mark_viewed: Vec<PathBuf>,
1683
1684 #[arg(long)]
1688 show_cleared: bool,
1689
1690 #[arg(long)]
1696 show_deprioritized: bool,
1697
1698 #[arg(value_name = "PATH")]
1701 path: Option<PathBuf>,
1702 },
1703
1704 AuditCache {
1706 #[command(subcommand)]
1707 subcommand: AuditCacheCli,
1708 },
1709
1710 DecisionSurface {
1724 #[arg(
1727 long,
1728 value_name = "N",
1729 default_value_t = audit_decision_surface::DEFAULT_DECISION_CAP
1730 )]
1731 max_decisions: usize,
1732 },
1733
1734 Impact {
1744 #[command(subcommand)]
1745 subcommand: Option<ImpactCli>,
1746 #[arg(long)]
1750 all: bool,
1751 #[arg(long, value_enum, default_value_t = ImpactSortCli::Recent)]
1753 sort: ImpactSortCli,
1754 #[arg(long)]
1757 limit: Option<usize>,
1758 },
1759
1760 Security {
1791 #[command(subcommand)]
1792 subcommand: Option<SecuritySubcommand>,
1793 #[arg(long, value_name = "PATH")]
1798 runtime_coverage: Option<PathBuf>,
1799 #[arg(long, default_value_t = 100)]
1802 min_invocations_hot: u64,
1803 #[arg(long, value_name = "PATH")]
1807 file: Vec<std::path::PathBuf>,
1808 #[arg(long, value_name = "MODE")]
1814 gate: Option<security::SecurityGateArg>,
1815 #[arg(long)]
1817 surface: bool,
1818
1819 #[arg(value_name = "PATH")]
1822 path: Option<PathBuf>,
1823 },
1824
1825 Report {
1830 #[arg(long, value_name = "PATH")]
1833 from: PathBuf,
1834 },
1835 Schema,
1837
1838 CiTemplate {
1845 #[command(subcommand)]
1846 subcommand: CiTemplateCli,
1847 },
1848
1849 Migrate {
1851 #[arg(long, conflicts_with = "jsonc")]
1853 toml: bool,
1854
1855 #[arg(long)]
1863 jsonc: bool,
1864
1865 #[arg(long)]
1867 dry_run: bool,
1868
1869 #[arg(long, value_name = "PATH")]
1871 from: Option<PathBuf>,
1872 },
1873
1874 License {
1881 #[command(subcommand)]
1882 subcommand: LicenseCli,
1883 },
1884
1885 Telemetry {
1893 #[command(subcommand)]
1894 subcommand: TelemetryCli,
1895 },
1896
1897 Coverage {
1903 #[command(subcommand)]
1904 subcommand: CoverageCli,
1905 },
1906
1907 SetupHooks {
1919 #[arg(long, value_enum)]
1921 agent: Option<setup_hooks::HookAgentArg>,
1922
1923 #[arg(long)]
1925 dry_run: bool,
1926
1927 #[arg(long)]
1930 force: bool,
1931
1932 #[arg(long)]
1934 user: bool,
1935
1936 #[arg(long)]
1938 gitignore_claude: bool,
1939
1940 #[arg(long)]
1944 uninstall: bool,
1945 },
1946
1947 Viz {
1949 #[arg(long = "out", value_name = "PATH")]
1951 output: Option<PathBuf>,
1952
1953 #[arg(long)]
1955 no_open: bool,
1956
1957 #[arg(long = "viz-format", default_value = "html")]
1959 viz_format: viz::VizFormat,
1960 },
1961}
1962
1963#[derive(Subcommand)]
1964enum SecuritySubcommand {
1965 Survivors {
1967 #[arg(long, value_name = "PATH")]
1969 candidates: PathBuf,
1970 #[arg(long, value_name = "PATH")]
1972 verdicts: PathBuf,
1973 #[arg(long)]
1975 require_verdict_for_each_candidate: bool,
1976 },
1977 #[command(name = "blind-spots")]
1979 BlindSpots {
1980 #[arg(long, value_name = "PATH")]
1982 file: Vec<PathBuf>,
1983 },
1984}
1985
1986#[derive(clap::Subcommand)]
1987enum AuditCacheCli {
1988 Remove {
1994 #[arg(long)]
1996 dry_run: bool,
1997
1998 #[arg(long, alias = "force")]
2000 yes: bool,
2001 },
2002
2003 Prune {
2016 #[arg(long)]
2018 dry_run: bool,
2019
2020 #[arg(long, value_name = "N")]
2027 max_age_days: Option<u32>,
2028 },
2029}
2030
2031#[derive(clap::Subcommand)]
2032enum LicenseCli {
2033 Activate {
2038 #[arg(value_name = "JWT")]
2040 jwt: Option<String>,
2041
2042 #[arg(long, value_name = "PATH")]
2044 from_file: Option<PathBuf>,
2045
2046 #[arg(long, conflicts_with_all = ["jwt", "from_file"])]
2048 stdin: bool,
2049
2050 #[arg(long, requires = "email")]
2057 trial: bool,
2058
2059 #[arg(long, value_name = "ADDR")]
2061 email: Option<String>,
2062 },
2063 Status,
2065 Refresh {
2071 #[arg(long, value_name = "KEY")]
2081 api_key: Option<String>,
2082 },
2083 Deactivate,
2085}
2086
2087#[derive(Clone, Copy, clap::Subcommand)]
2088enum TelemetryCli {
2089 Status,
2091 Enable,
2093 Disable,
2095 Inspect {
2097 #[arg(long)]
2099 example: bool,
2100 },
2101}
2102
2103#[derive(clap::Subcommand)]
2104enum CiTemplateCli {
2105 Gitlab {
2107 #[arg(long, value_name = "DIR", num_args = 0..=1, default_missing_value = ".")]
2111 vendor: Option<PathBuf>,
2112
2113 #[arg(long)]
2115 force: bool,
2116 },
2117}
2118
2119#[derive(clap::Subcommand)]
2120enum CoverageCli {
2121 Setup {
2123 #[arg(short = 'y', long)]
2125 yes: bool,
2126
2127 #[arg(long)]
2129 non_interactive: bool,
2130
2131 #[arg(long)]
2133 json: bool,
2134 },
2135 Analyze {
2141 #[arg(long, value_name = "PATH", conflicts_with = "cloud")]
2143 runtime_coverage: Option<PathBuf>,
2144
2145 #[arg(long, visible_alias = "runtime-coverage-cloud")]
2147 cloud: bool,
2148
2149 #[arg(long, value_name = "KEY")]
2151 api_key: Option<String>,
2152
2153 #[arg(long, value_name = "URL")]
2155 api_endpoint: Option<String>,
2156
2157 #[arg(long, value_name = "OWNER/REPO")]
2163 repo: Option<String>,
2164
2165 #[arg(long, value_name = "ID")]
2167 project_id: Option<String>,
2168
2169 #[arg(long, value_name = "DAYS", default_value_t = 30)]
2171 coverage_period: u16,
2172
2173 #[arg(long, value_name = "ENV")]
2175 environment: Option<String>,
2176
2177 #[arg(long, value_name = "SHA")]
2179 commit_sha: Option<String>,
2180
2181 #[arg(long)]
2183 production: bool,
2184
2185 #[arg(long, default_value_t = 100)]
2187 min_invocations_hot: u64,
2188
2189 #[arg(long, value_name = "N")]
2191 min_observation_volume: Option<u32>,
2192
2193 #[arg(long, value_name = "RATIO")]
2195 low_traffic_threshold: Option<f64>,
2196
2197 #[arg(long)]
2199 top: Option<usize>,
2200
2201 #[arg(long)]
2203 blast_radius: bool,
2204
2205 #[arg(long)]
2207 importance: bool,
2208
2209 #[arg(long)]
2211 debug_unmatched: bool,
2212 },
2213 UploadInventory {
2224 #[arg(long, value_name = "KEY")]
2233 api_key: Option<String>,
2234
2235 #[arg(long, value_name = "URL")]
2240 api_endpoint: Option<String>,
2241
2242 #[arg(long, value_name = "PROJECT_ID")]
2247 project_id: Option<String>,
2248
2249 #[arg(long, value_name = "SHA")]
2254 git_sha: Option<String>,
2255
2256 #[arg(long)]
2262 allow_dirty: bool,
2263
2264 #[arg(long, value_name = "GLOB", num_args = 0..)]
2268 exclude_paths: Vec<String>,
2269
2270 #[arg(long, value_name = "PREFIX")]
2283 path_prefix: Option<String>,
2284
2285 #[arg(long)]
2287 dry_run: bool,
2288
2289 #[arg(long)]
2295 with_callers: bool,
2296
2297 #[arg(long)]
2301 ignore_upload_errors: bool,
2302 },
2303 UploadSourceMaps {
2316 #[arg(long, value_name = "PATH", default_value = "dist")]
2318 dir: PathBuf,
2319
2320 #[arg(long, value_name = "GLOB", default_value = "**/*.map")]
2322 include: String,
2323
2324 #[arg(long, value_name = "GLOB", default_value = "**/node_modules/**")]
2328 exclude: Vec<String>,
2329
2330 #[arg(long, value_name = "NAME")]
2334 repo: Option<String>,
2335
2336 #[arg(long, value_name = "SHA")]
2341 git_sha: Option<String>,
2342
2343 #[arg(long, value_name = "URL")]
2345 endpoint: Option<String>,
2346
2347 #[arg(long, value_name = "BOOL", default_value_t = true, action = clap::ArgAction::Set)]
2352 strip_path: bool,
2353
2354 #[arg(long)]
2356 dry_run: bool,
2357
2358 #[arg(long, value_name = "N", default_value_t = 4)]
2360 concurrency: usize,
2361
2362 #[arg(long)]
2364 fail_fast: bool,
2365 },
2366 UploadStaticFindings {
2373 #[arg(long, value_name = "KEY")]
2383 api_key: Option<String>,
2384
2385 #[arg(long, value_name = "URL")]
2390 api_endpoint: Option<String>,
2391
2392 #[arg(long, value_name = "PROJECT_ID")]
2397 project_id: Option<String>,
2398
2399 #[arg(long, value_name = "SHA")]
2404 git_sha: Option<String>,
2405
2406 #[arg(long)]
2412 allow_dirty: bool,
2413
2414 #[arg(long)]
2416 dry_run: bool,
2417
2418 #[arg(long)]
2422 ignore_upload_errors: bool,
2423 },
2424}
2425
2426#[derive(Subcommand)]
2427enum CiCli {
2428 PlanPrComment {
2430 #[arg(long)]
2432 body: PathBuf,
2433
2434 #[arg(long)]
2436 marker_id: String,
2437
2438 #[arg(long)]
2440 clean: bool,
2441
2442 #[arg(long)]
2444 existing_comment_id: Option<String>,
2445
2446 #[arg(long)]
2448 existing_body: Option<PathBuf>,
2449 },
2450
2451 PostPrComment {
2453 #[arg(long, value_enum)]
2455 provider: CiProviderArg,
2456
2457 #[arg(long)]
2459 pr: Option<String>,
2460
2461 #[arg(long)]
2463 mr: Option<String>,
2464
2465 #[arg(long)]
2467 body: PathBuf,
2468
2469 #[arg(long)]
2471 envelope: Option<PathBuf>,
2472
2473 #[arg(long)]
2475 marker_id: String,
2476
2477 #[arg(long)]
2479 clean: bool,
2480
2481 #[arg(long)]
2483 repo: Option<String>,
2484
2485 #[arg(long = "project-id")]
2487 project_id: Option<String>,
2488
2489 #[arg(long = "api-url")]
2491 api_url: Option<String>,
2492
2493 #[arg(long)]
2495 dry_run: bool,
2496 },
2497
2498 PostReview {
2500 #[arg(long, value_enum)]
2502 provider: CiProviderArg,
2503
2504 #[arg(long)]
2506 pr: Option<String>,
2507
2508 #[arg(long)]
2510 mr: Option<String>,
2511
2512 #[arg(long)]
2514 envelope: PathBuf,
2515
2516 #[arg(long)]
2518 repo: Option<String>,
2519
2520 #[arg(long = "project-id")]
2522 project_id: Option<String>,
2523
2524 #[arg(long = "api-url")]
2526 api_url: Option<String>,
2527
2528 #[arg(long)]
2530 dry_run: bool,
2531 },
2532
2533 PostCheckRun {
2535 #[arg(long, value_enum)]
2537 provider: CiProviderArg,
2538
2539 #[arg(long)]
2541 decision: PathBuf,
2542
2543 #[arg(long)]
2545 repo: String,
2546
2547 #[arg(long = "head-sha")]
2549 head_sha: String,
2550
2551 #[arg(long = "api-url")]
2553 api_url: Option<String>,
2554
2555 #[arg(long = "split-gates")]
2557 split_gates: bool,
2558
2559 #[arg(long)]
2561 dry_run: bool,
2562 },
2563
2564 ReconcileReview {
2566 #[arg(long, value_enum)]
2568 provider: CiProviderArg,
2569
2570 #[arg(long)]
2572 pr: Option<String>,
2573
2574 #[arg(long)]
2576 mr: Option<String>,
2577
2578 #[arg(long)]
2580 envelope: PathBuf,
2581
2582 #[arg(long)]
2584 repo: Option<String>,
2585
2586 #[arg(long = "project-id")]
2588 project_id: Option<String>,
2589
2590 #[arg(long = "api-url")]
2592 api_url: Option<String>,
2593
2594 #[arg(long)]
2596 dry_run: bool,
2597 },
2598}
2599
2600#[derive(Subcommand)]
2601enum RulePackCli {
2602 Init {
2604 name: Option<String>,
2606
2607 #[arg(long, default_value = "starter")]
2609 template: String,
2610
2611 #[arg(long, default_value = "rule-packs")]
2613 dir: String,
2614
2615 #[arg(long)]
2617 no_config: bool,
2618 },
2619
2620 List,
2622
2623 Test {
2625 pack: Option<PathBuf>,
2627 },
2628
2629 Schema,
2631}
2632
2633#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
2635pub enum BaselineModeArg {
2636 #[default]
2638 Count,
2639 Identity,
2642}
2643
2644impl From<BaselineModeArg> for fallow_engine::baseline::HealthBaselineMode {
2645 fn from(value: BaselineModeArg) -> Self {
2646 match value {
2647 BaselineModeArg::Count => Self::Count,
2648 BaselineModeArg::Identity => Self::Identity,
2649 }
2650 }
2651}
2652
2653#[derive(Clone, Copy, Debug, clap::ValueEnum)]
2654enum CiProviderArg {
2655 Github,
2656 Gitlab,
2657}
2658
2659#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2661enum TypeAwareRequireArg {
2662 BestEffort,
2664 Complete,
2666}
2667
2668impl From<TypeAwareRequireArg> for fallow_config::TypeAwareRequire {
2669 fn from(value: TypeAwareRequireArg) -> Self {
2670 match value {
2671 TypeAwareRequireArg::BestEffort => Self::BestEffort,
2672 TypeAwareRequireArg::Complete => Self::Complete,
2673 }
2674 }
2675}
2676
2677#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2679pub enum EffortFilter {
2680 Low,
2681 Medium,
2682 High,
2683}
2684
2685impl EffortFilter {
2686 const fn to_estimate(self) -> fallow_output::EffortEstimate {
2688 match self {
2689 Self::Low => fallow_output::EffortEstimate::Low,
2690 Self::Medium => fallow_output::EffortEstimate::Medium,
2691 Self::High => fallow_output::EffortEstimate::High,
2692 }
2693 }
2694}
2695
2696#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2698pub enum HealthSeverityCli {
2699 Moderate,
2700 High,
2701 Critical,
2702}
2703
2704impl HealthSeverityCli {
2705 const fn to_health_severity(self) -> fallow_output::FindingSeverity {
2707 match self {
2708 Self::Moderate => fallow_output::FindingSeverity::Moderate,
2709 Self::High => fallow_output::FindingSeverity::High,
2710 Self::Critical => fallow_output::FindingSeverity::Critical,
2711 }
2712 }
2713}
2714
2715#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2721pub enum EmailModeArg {
2722 Raw,
2724 Handle,
2726 Anonymized,
2728 #[value(hide = true)]
2730 Hash,
2731}
2732
2733impl EmailModeArg {
2734 const fn to_config(self) -> fallow_config::EmailMode {
2736 match self {
2737 Self::Raw => fallow_config::EmailMode::Raw,
2738 Self::Handle => fallow_config::EmailMode::Handle,
2739 Self::Anonymized => fallow_config::EmailMode::Anonymized,
2740 Self::Hash => fallow_config::EmailMode::Hash,
2741 }
2742 }
2743}
2744
2745#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2747pub enum AuditGateArg {
2748 NewOnly,
2750 All,
2752}
2753
2754impl From<AuditGateArg> for fallow_config::AuditGate {
2755 fn from(value: AuditGateArg) -> Self {
2756 match value {
2757 AuditGateArg::NewOnly => Self::NewOnly,
2758 AuditGateArg::All => Self::All,
2759 }
2760 }
2761}
2762
2763fn parse_min_occurrences(s: &str) -> Result<usize, String> {
2767 let value: usize = s
2768 .parse()
2769 .map_err(|_| format!("`{s}` is not a non-negative integer"))?;
2770 if value < 2 {
2771 return Err(format!(
2772 "must be at least 2 (got {value}); a single occurrence isn't a duplicate"
2773 ));
2774 }
2775 Ok(value)
2776}
2777
2778fn resolve_audit_baseline_path(
2784 root: &std::path::Path,
2785 cli: Option<&std::path::Path>,
2786 config: Option<&str>,
2787) -> Option<PathBuf> {
2788 let path = cli.map(std::path::Path::to_path_buf).or_else(|| {
2789 config.map(|p| {
2790 let path = PathBuf::from(p);
2791 if path_util::is_absolute_path_any_platform(&path) {
2792 path
2793 } else {
2794 root.join(path)
2795 }
2796 })
2797 })?;
2798 if path_util::is_absolute_path_any_platform(&path) {
2799 Some(path)
2800 } else {
2801 Some(root.join(path))
2802 }
2803}
2804
2805fn emit_known_failure(
2806 message: &str,
2807 exit_code: u8,
2808 output: fallow_config::OutputFormat,
2809 reason: telemetry::FailureReason,
2810) -> ExitCode {
2811 telemetry::note_failure_reason(reason);
2812 emit_error(message, exit_code, output)
2813}
2814
2815fn emit_known_failure_with_style(
2816 message: &str,
2817 exit_code: u8,
2818 output: fallow_config::OutputFormat,
2819 json_style: json_style::JsonStyle,
2820 reason: telemetry::FailureReason,
2821) -> ExitCode {
2822 telemetry::note_failure_reason(reason);
2823 error::emit_error_with_style(message, exit_code, output, json_style)
2824}
2825
2826fn unsupported_security_global(cli: &Cli) -> Option<&'static str> {
2827 if cli.baseline.is_some() {
2828 Some("--baseline")
2829 } else if cli.save_baseline.is_some() {
2830 Some("--save-baseline")
2831 } else if cli.fail_on_stale_baseline {
2832 Some("--fail-on-stale-baseline")
2833 } else if cli.production {
2834 Some("--production")
2835 } else if cli.no_production {
2836 Some("--no-production")
2837 } else if cli.group_by.is_some() {
2838 Some("--group-by")
2839 } else if cli.performance {
2840 Some("--performance")
2841 } else if cli.explain_skipped {
2842 Some("--explain-skipped")
2843 } else if cli.fail_on_regression {
2844 Some("--fail-on-regression")
2845 } else if cli.regression_baseline.is_some() {
2846 Some("--regression-baseline")
2847 } else if cli.save_regression_baseline.is_some() {
2848 Some("--save-regression-baseline")
2849 } else if cli.dupes_mode.is_some() {
2850 Some("--dupes-mode")
2851 } else if cli.dupes_threshold.is_some() {
2852 Some("--dupes-threshold")
2853 } else if cli.dupes_min_tokens.is_some() {
2854 Some("--dupes-min-tokens")
2855 } else if cli.dupes_min_lines.is_some() {
2856 Some("--dupes-min-lines")
2857 } else if cli.dupes_min_occurrences.is_some() {
2858 Some("--dupes-min-occurrences")
2859 } else if cli.dupes_skip_local {
2860 Some("--dupes-skip-local")
2861 } else if cli.dupes_cross_language {
2862 Some("--dupes-cross-language")
2863 } else if cli.dupes_ignore_imports {
2864 Some("--dupes-ignore-imports")
2865 } else if cli.dupes_no_ignore_imports {
2866 Some("--dupes-no-ignore-imports")
2867 } else if cli.include_entry_exports {
2868 Some("--include-entry-exports")
2869 } else {
2870 None
2871 }
2872}
2873
2874struct DispatchContext<'a> {
2875 cli: &'a Cli,
2876 root: &'a std::path::Path,
2877 output: fallow_config::OutputFormat,
2878 quiet: bool,
2879 fail_on_issues: bool,
2880 json_style: json_style::JsonStyle,
2881 threads: usize,
2882 tolerance: regression::Tolerance,
2883 save_regression_file: Option<&'a std::path::PathBuf>,
2884 save_to_config: bool,
2885}
2886
2887impl DispatchContext<'_> {
2888 fn production_modes(
2889 &self,
2890 dead_code: bool,
2891 health: bool,
2892 dupes: bool,
2893 ) -> Result<ProductionModes, ExitCode> {
2894 resolve_production_modes(self.cli, self.root, self.output, dead_code, health, dupes)
2895 }
2896
2897 fn production_for(
2898 &self,
2899 analysis: fallow_config::ProductionAnalysis,
2900 ) -> Result<bool, ExitCode> {
2901 self.production_modes(false, false, false)
2902 .map(|modes| modes.for_analysis(analysis))
2903 }
2904
2905 fn regression_opts(&self, scoped: bool) -> regression::RegressionOpts<'_> {
2906 regression::RegressionOpts {
2907 fail_on_regression: self.cli.fail_on_regression,
2908 tolerance: self.tolerance,
2909 regression_baseline_file: self.cli.regression_baseline.as_deref(),
2910 save_target: if let Some(path) = self.save_regression_file {
2911 regression::SaveRegressionTarget::File(path)
2912 } else if self.save_to_config {
2913 regression::SaveRegressionTarget::Config
2914 } else {
2915 regression::SaveRegressionTarget::None
2916 },
2917 scoped,
2918 quiet: self.quiet,
2919 output: self.output,
2920 }
2921 }
2922}
2923
2924#[cfg(unix)]
2939fn signal_test_helper() -> ExitCode {
2940 use std::io::Write as _;
2941 use std::process::Command;
2942
2943 if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER_GRACEFUL").is_some() {
2944 signal::set_graceful_mode();
2945 }
2946
2947 let mut command = Command::new("sleep");
2948 command.arg("30");
2949 let child = match signal::ScopedChild::spawn(&mut command) {
2950 Ok(c) => c,
2951 Err(err) => {
2952 let _ = writeln!(std::io::stderr(), "spawn sleep failed: {err}");
2953 return ExitCode::from(2);
2954 }
2955 };
2956 let pid = child.id();
2957 let stdout = std::io::stdout();
2958 let mut lock = stdout.lock();
2959 let _ = writeln!(lock, "{pid}");
2960 let _ = lock.flush();
2961 drop(lock);
2962 let _ = child.wait_with_output();
2963 if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER_GRACEFUL").is_some() {
2964 return ExitCode::SUCCESS;
2965 }
2966 std::thread::sleep(std::time::Duration::from_secs(5));
2967 ExitCode::SUCCESS
2968}
2969
2970#[cfg(not(unix))]
2971fn signal_test_helper() -> ExitCode {
2972 ExitCode::from(2)
2973}
2974
2975fn install_spawn_hooks() {
2976 fallow_engine::churn::set_spawn_hook(signal::scoped_child::output);
2977 fallow_engine::changed_files::set_spawn_hook(signal::scoped_child::output);
2978}
2979
2980fn install_signal_handlers() {
2981 if let Err(err) = signal::install_handlers() {
2982 use std::io::Write as _;
2983 let stderr = std::io::stderr();
2984 let mut lock = stderr.lock();
2985 let _ = writeln!(lock, "fallow: failed to install signal handlers: {err}");
2986 }
2987}
2988
2989fn redirect_report_to_file(
2994 path: &std::path::Path,
2995 output: fallow_config::OutputFormat,
2996) -> Result<(), ExitCode> {
2997 if let Some(parent) = path.parent()
2998 && !parent.as_os_str().is_empty()
2999 && let Err(e) = std::fs::create_dir_all(parent)
3000 {
3001 return Err(emit_error(
3002 &format!(
3003 "failed to create {} for --output-file: {e}",
3004 parent.display()
3005 ),
3006 2,
3007 output,
3008 ));
3009 }
3010 match std::fs::File::create(path) {
3011 Ok(file) => {
3012 report::sink::set_file_sink(file);
3013 colored::control::set_override(false);
3014 Ok(())
3015 }
3016 Err(e) => Err(emit_error(
3017 &format!("failed to open {} for --output-file: {e}", path.display()),
3018 2,
3019 output,
3020 )),
3021 }
3022}
3023
3024fn finalize_report_file(
3027 path: &std::path::Path,
3028 quiet: bool,
3029 output: fallow_config::OutputFormat,
3030) -> Result<(), ExitCode> {
3031 if let Err(e) = report::sink::flush() {
3032 return Err(emit_error(
3033 &format!("failed to write {}: {e}", path.display()),
3034 2,
3035 output,
3036 ));
3037 }
3038 if !quiet && report::sink::wrote() {
3042 eprintln!("Report written to {}", path.display());
3043 }
3044 Ok(())
3045}
3046
3047pub fn run() -> ExitCode {
3052 install_signal_handlers();
3053 install_spawn_hooks();
3054
3055 if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER").is_some() {
3056 return signal_test_helper();
3057 }
3058
3059 let (mut cli, fmt) = match parse_cli_args() {
3060 Ok(parsed) => parsed,
3061 Err(code) => return code,
3062 };
3063 if cli.pretty && !fmt.payload_is_json {
3064 eprintln!(
3065 "Error: --pretty requires JSON output. Use --format json --pretty, or remove --pretty."
3066 );
3067 return ExitCode::from(2);
3068 }
3069
3070 if let Some(code) = run_schema_command_if_requested(&cli, fmt.json_style) {
3071 return code;
3072 }
3073
3074 if let Some(code) = run_telemetry_command_if_requested(&mut cli, fmt.output, fmt.json_style) {
3075 return code;
3076 }
3077 if let Some(code) = run_doctor_command_if_requested(&cli, &fmt) {
3078 return code;
3079 }
3080 if is_impact_statusline(&cli) {
3081 let (root, _) = match validate_inputs(&cli, fmt.output, fmt.json_style) {
3082 Ok(validated) => validated,
3083 Err(code) => return code,
3084 };
3085 return cli_impact::render_impact_statusline(&root);
3086 }
3087 let telemetry_run = start_telemetry_run(&cli, &fmt);
3088
3089 let (root, threads) = match validate_inputs(&cli, fmt.output, fmt.json_style) {
3090 Ok(v) => v,
3091 Err(code) => {
3092 return record_run_epilogue(telemetry_run, code, None, cli.parent_run.as_deref());
3093 }
3094 };
3095
3096 let FormatConfig {
3097 output,
3098 payload_is_json: _,
3099 quiet,
3100 fail_on_issues,
3101 json_style,
3102 } = fmt;
3103
3104 let tolerance =
3105 match run_pre_dispatch_checks(&cli, &root, output, json_style, quiet, telemetry_run) {
3106 Ok(tolerance) => tolerance,
3107 Err(code) => return code,
3108 };
3109
3110 let (save_regression_file, save_to_config) = regression_save_targets(&cli);
3111
3112 let command = cli.command.take();
3113 let dispatch = DispatchContext {
3114 cli: &cli,
3115 root: &root,
3116 output,
3117 quiet,
3118 fail_on_issues,
3119 json_style,
3120 threads,
3121 tolerance,
3122 save_regression_file: save_regression_file.as_ref(),
3123 save_to_config,
3124 };
3125 let exit_code = match dispatch_and_finalize(&dispatch, command) {
3126 Ok(code) => code,
3127 Err(code) => return code,
3128 };
3129 record_run_epilogue(telemetry_run, exit_code, None, cli.parent_run.as_deref())
3130}
3131
3132#[doc(hidden)]
3136pub fn benchmark_fix_dry_run(root: &Path, threads: usize) -> (ExitCode, usize) {
3137 let config_path = None;
3138 fix::run_fix_with_count(&fix::FixOptions {
3139 root,
3140 config_path: &config_path,
3141 output: fallow_config::OutputFormat::Json,
3142 json_style: json_style::JsonStyle::Compact,
3143 no_cache: true,
3144 threads,
3145 quiet: true,
3146 emit_output: false,
3147 allow_remote_extends: false,
3148 dry_run: true,
3149 yes: false,
3150 production: false,
3151 no_create_config: true,
3152 type_aware: None,
3153 type_aware_projects: &[],
3154 type_aware_require: None,
3155 scope: None,
3156 })
3157}
3158
3159#[doc(hidden)]
3162pub use audit::AuditReviewBenchmarkCorpus;
3163
3164#[doc(hidden)]
3167pub fn create_audit_review_benchmark_corpus(
3168 root: &Path,
3169 changed_files: &[PathBuf],
3170 threads: usize,
3171) -> Result<AuditReviewBenchmarkCorpus, ExitCode> {
3172 audit::create_audit_review_benchmark_corpus(root, changed_files, threads)
3173}
3174
3175#[doc(hidden)]
3178pub fn benchmark_audit_review_brief_many_changed_files_json(
3179 corpus: &mut AuditReviewBenchmarkCorpus,
3180) -> (ExitCode, usize, usize, usize, usize, usize) {
3181 match audit::benchmark_audit_review_brief_many_changed_files_json(corpus) {
3182 Ok(result) => (
3183 ExitCode::SUCCESS,
3184 result.introduced_count,
3185 result.inherited_count,
3186 result.public_api_added_count,
3187 result.decision_count,
3188 result.rendered_bytes,
3189 ),
3190 Err(code) => (code, 0, 0, 0, 0, 0),
3191 }
3192}
3193
3194#[doc(hidden)]
3195pub use inspect::InspectBenchmarkCorpus;
3196
3197#[doc(hidden)]
3200pub fn create_inspect_benchmark_corpus(root: &Path, threads: usize) -> InspectBenchmarkCorpus {
3201 inspect::create_inspect_benchmark_corpus(root, threads)
3202}
3203
3204#[doc(hidden)]
3207pub fn benchmark_inspect_file_evidence_bundle_json(
3208 root: &Path,
3209 threads: usize,
3210 corpus: &InspectBenchmarkCorpus,
3211) -> (ExitCode, usize, usize) {
3212 match inspect::benchmark_inspect_file_evidence_bundle_json(root, threads, corpus) {
3213 Ok((child_call_count, rendered_bytes)) => {
3214 (ExitCode::SUCCESS, child_call_count, rendered_bytes)
3215 }
3216 Err(_) => (ExitCode::from(2), 0, 0),
3217 }
3218}
3219
3220#[doc(hidden)]
3223pub fn benchmark_dead_code_json(root: &Path, threads: usize) -> (ExitCode, usize, usize) {
3224 match check::benchmark_dead_code_json(root, threads) {
3225 Ok((issue_count, rendered_bytes)) => (ExitCode::SUCCESS, issue_count, rendered_bytes),
3226 Err(code) => (code, 0, 0),
3227 }
3228}
3229
3230#[doc(hidden)]
3233pub fn benchmark_security_json(root: &Path, threads: usize) -> (ExitCode, usize, usize) {
3234 match security::benchmark_security_json(root, threads) {
3235 Ok((finding_count, rendered_bytes)) => (ExitCode::SUCCESS, finding_count, rendered_bytes),
3236 Err(code) => (code, 0, 0),
3237 }
3238}
3239
3240#[doc(hidden)]
3241pub use security::{SecurityBlindSpotsBenchmarkResult, SecuritySurvivorsBenchmarkCorpus};
3242
3243#[doc(hidden)]
3246pub fn create_security_survivors_benchmark_corpus(
3247 root: &Path,
3248 threads: usize,
3249) -> Result<SecuritySurvivorsBenchmarkCorpus, ExitCode> {
3250 security::create_security_survivors_benchmark_corpus(root, threads)
3251}
3252
3253#[doc(hidden)]
3256pub fn benchmark_security_survivors_json(
3257 corpus: &SecuritySurvivorsBenchmarkCorpus,
3258) -> (ExitCode, usize, usize, usize, usize, usize) {
3259 match security::benchmark_security_survivors_json(corpus) {
3260 Ok((survivors, dismissed, needs_human_review, unverdicted, rendered_bytes)) => (
3261 ExitCode::SUCCESS,
3262 survivors,
3263 dismissed,
3264 needs_human_review,
3265 unverdicted,
3266 rendered_bytes,
3267 ),
3268 Err(_) => (ExitCode::from(2), 0, 0, 0, 0, 0),
3269 }
3270}
3271
3272#[doc(hidden)]
3275pub fn benchmark_security_blind_spots_json(
3276 root: &Path,
3277 diagnostics: &[fallow_types::results::SecurityUnresolvedCalleeDiagnostic],
3278) -> SecurityBlindSpotsBenchmarkResult {
3279 security::benchmark_security_blind_spots_json(root, diagnostics)
3280}
3281
3282#[doc(hidden)]
3285pub fn benchmark_list_json(root: &Path, threads: usize) -> (ExitCode, usize, usize, usize, usize) {
3286 match list::benchmark_list_json(root, threads) {
3287 Ok((file_count, entry_point_count, workspace_count, rendered_bytes)) => (
3288 ExitCode::SUCCESS,
3289 file_count,
3290 entry_point_count,
3291 workspace_count,
3292 rendered_bytes,
3293 ),
3294 Err(code) => (code, 0, 0, 0, 0),
3295 }
3296}
3297
3298#[doc(hidden)]
3301pub fn benchmark_list_boundaries_json(
3302 root: &Path,
3303 threads: usize,
3304) -> (ExitCode, usize, usize, usize, usize) {
3305 match list::benchmark_list_boundaries_json(root, threads) {
3306 Ok((zone_count, rule_count, matched_file_count, rendered_bytes)) => (
3307 ExitCode::SUCCESS,
3308 zone_count,
3309 rule_count,
3310 matched_file_count,
3311 rendered_bytes,
3312 ),
3313 Err(code) => (code, 0, 0, 0, 0),
3314 }
3315}
3316
3317#[doc(hidden)]
3320pub use watch::WatchFilterBenchmarkGlobalGitignore;
3321
3322#[doc(hidden)]
3325pub fn create_watch_filter_benchmark_global_gitignore() -> WatchFilterBenchmarkGlobalGitignore {
3326 watch::create_benchmark_global_gitignore()
3327}
3328
3329#[doc(hidden)]
3332pub fn benchmark_watch_filter_initialization(
3333 config: &fallow_config::ResolvedConfig,
3334 global_gitignore: &WatchFilterBenchmarkGlobalGitignore,
3335) -> (usize, usize) {
3336 watch::benchmark_filter_initialization(config, global_gitignore)
3337}
3338
3339#[doc(hidden)]
3342pub fn benchmark_viz_html(root: &Path, threads: usize) -> (ExitCode, usize, usize, usize) {
3343 match viz::benchmark_viz_html(root, threads) {
3344 Ok((file_count, edge_count, rendered_bytes)) => {
3345 (ExitCode::SUCCESS, file_count, edge_count, rendered_bytes)
3346 }
3347 Err(code) => (code, 0, 0, 0),
3348 }
3349}
3350
3351#[doc(hidden)]
3354pub fn benchmark_rule_pack_test_json(root: &Path, threads: usize) -> (ExitCode, usize, usize) {
3355 match rule_pack::benchmark_rule_pack_test_json(root, threads) {
3356 Ok((finding_count, rendered_bytes)) => (ExitCode::SUCCESS, finding_count, rendered_bytes),
3357 Err(code) => (code, 0, 0),
3358 }
3359}
3360
3361#[doc(hidden)]
3364pub fn benchmark_recommend_json(root: &Path) -> (ExitCode, usize, usize, bool, usize) {
3365 match onboarding::benchmark_recommend_json(root) {
3366 Ok((decision_count, framework_count, heterogeneous, rendered_bytes)) => (
3367 ExitCode::SUCCESS,
3368 decision_count,
3369 framework_count,
3370 heterogeneous,
3371 rendered_bytes,
3372 ),
3373 Err(_) => (ExitCode::from(2), 0, 0, false, 0),
3374 }
3375}
3376
3377#[doc(hidden)]
3380pub fn benchmark_runtime_coverage_analyze_json(
3381 root: &Path,
3382 runtime_coverage_path: &Path,
3383 response_bytes: &[u8],
3384 threads: usize,
3385) -> (ExitCode, usize, usize, usize, String) {
3386 match coverage::benchmark_local_json(root, runtime_coverage_path, response_bytes, threads) {
3387 Ok((finding_count, hot_path_count, request_bytes, rendered)) => (
3388 ExitCode::SUCCESS,
3389 finding_count,
3390 hot_path_count,
3391 request_bytes,
3392 rendered,
3393 ),
3394 Err(code) => (code, 0, 0, 0, String::new()),
3395 }
3396}
3397
3398fn is_impact_statusline(cli: &Cli) -> bool {
3401 matches!(
3402 cli.command.as_ref(),
3403 Some(Command::Impact {
3404 subcommand: Some(ImpactCli::Statusline),
3405 all: false,
3406 ..
3407 })
3408 )
3409}
3410
3411fn dispatch_and_finalize(
3415 dispatch: &DispatchContext<'_>,
3416 command: Option<Command>,
3417) -> Result<ExitCode, ExitCode> {
3418 let cli = dispatch.cli;
3419 let output = dispatch.output;
3420 let quiet = dispatch.quiet;
3421
3422 if let Some(path) = cli.output_file.as_deref()
3425 && let Err(code) = redirect_report_to_file(path, output)
3426 {
3427 return Err(code);
3428 }
3429
3430 let exit_code = if command.is_some() && cli_has_bare_coverage_input(cli) {
3431 emit_error(bare_coverage_subcommand_error_message(), 2, output)
3432 } else {
3433 match command {
3434 None => dispatch_bare_command(dispatch),
3435 Some(cmd) => dispatch_subcommand(cmd, dispatch),
3436 }
3437 };
3438
3439 if let Some(path) = cli.output_file.as_deref()
3440 && let Err(code) = finalize_report_file(path, quiet, output)
3441 {
3442 return Err(code);
3443 }
3444 Ok(exit_code)
3445}
3446
3447fn run_telemetry_command_if_requested(
3448 cli: &mut Cli,
3449 output: fallow_config::OutputFormat,
3450 json_style: json_style::JsonStyle,
3451) -> Option<ExitCode> {
3452 if matches!(cli.command, Some(Command::Telemetry { .. }))
3453 && let Some(Command::Telemetry { subcommand }) = cli.command.take()
3454 {
3455 return Some(telemetry::run(
3456 map_telemetry_subcommand(subcommand),
3457 output,
3458 json_style,
3459 ));
3460 }
3461 None
3462}
3463
3464fn run_doctor_command_if_requested(cli: &Cli, format: &FormatConfig) -> Option<ExitCode> {
3469 if !matches!(cli.command, Some(Command::Doctor)) {
3470 return None;
3471 }
3472
3473 if let Some(flag) = unsupported_doctor_option(cli) {
3474 let message = if flag == "--output-file" {
3475 "--output-file is not valid with `fallow doctor`; doctor is read-only and writes its report to stdout".to_string()
3476 } else {
3477 format!("{flag} is not valid with `fallow doctor`.")
3478 };
3479 return Some(crate::error::emit_error_with_style(
3480 &message,
3481 2,
3482 format.output,
3483 format.json_style,
3484 ));
3485 }
3486 if let Err(code) = doctor::validate_output(format.output, format.json_style) {
3487 return Some(code);
3488 }
3489
3490 let root = cli.root.clone().unwrap_or_else(|| {
3491 std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
3492 });
3493 let config_path = cli.config.as_ref().map(|path| {
3494 if path_util::is_absolute_path_any_platform(path) {
3495 path.clone()
3496 } else {
3497 root.join(path)
3498 }
3499 });
3500 let report = doctor::collect_report(&root, config_path.as_deref());
3501 Some(doctor::render_report(
3502 &report,
3503 format.output,
3504 format.json_style,
3505 ))
3506}
3507
3508fn unsupported_doctor_option(cli: &Cli) -> Option<&'static str> {
3512 [
3513 (cli.allow_remote_extends, "--allow-remote-extends"),
3514 (cli.no_cache, "--no-cache"),
3515 (cli.threads.is_some(), "--threads"),
3516 (cli.changed_since.is_some(), "--changed-since"),
3517 (cli.diff_file.is_some(), "--diff-file"),
3518 (cli.diff_stdin, "--diff-stdin"),
3519 (cli.churn_file.is_some(), "--churn-file"),
3520 (cli.max_file_size.is_some(), "--max-file-size"),
3521 (cli.baseline.is_some(), "--baseline"),
3522 (cli.baseline_mode.is_some(), "--baseline-mode"),
3523 (cli.parent_run.is_some(), "--parent-run"),
3524 (cli.save_baseline.is_some(), "--save-baseline"),
3525 (cli.production, "--production"),
3526 (cli.no_production, "--no-production"),
3527 (cli.production_dead_code, "--production-dead-code"),
3528 (cli.production_health, "--production-health"),
3529 (cli.production_dupes, "--production-dupes"),
3530 (cli.workspace.is_some(), "--workspace"),
3531 (cli.changed_workspaces.is_some(), "--changed-workspaces"),
3532 (cli.group_by.is_some(), "--group-by"),
3533 (cli.performance, "--performance"),
3534 (cli.explain, "--explain"),
3535 (cli.explain_skipped, "--explain-skipped"),
3536 (cli.summary, "--summary"),
3537 (cli.ci, "--ci"),
3538 (cli.fail_on_issues, "--fail-on-issues"),
3539 (cli.sarif_file.is_some(), "--sarif-file"),
3540 (cli.output_file.is_some(), "--output-file"),
3541 (cli.report_path_prefix.is_some(), "--report-path-prefix"),
3542 (cli.fail_on_regression, "--fail-on-regression"),
3543 (cli.fail_on_stale_baseline, "--fail-on-stale-baseline"),
3544 (cli.tolerance != "0", "--tolerance"),
3545 (cli.regression_baseline.is_some(), "--regression-baseline"),
3546 (
3547 cli.save_regression_baseline.is_some(),
3548 "--save-regression-baseline",
3549 ),
3550 (!cli.only.is_empty(), "--only"),
3551 (!cli.skip.is_empty(), "--skip"),
3552 (cli.dupes_mode.is_some(), "--dupes-mode"),
3553 (cli.dupes_near, "--dupes-near"),
3554 (cli.dupes_threshold.is_some(), "--dupes-threshold"),
3555 (cli.dupes_min_tokens.is_some(), "--dupes-min-tokens"),
3556 (cli.dupes_min_lines.is_some(), "--dupes-min-lines"),
3557 (
3558 cli.dupes_min_occurrences.is_some(),
3559 "--dupes-min-occurrences",
3560 ),
3561 (cli.dupes_skip_local, "--dupes-skip-local"),
3562 (cli.dupes_cross_language, "--dupes-cross-language"),
3563 (cli.dupes_ignore_imports, "--dupes-ignore-imports"),
3564 (cli.dupes_no_ignore_imports, "--dupes-no-ignore-imports"),
3565 (cli.score, "--score"),
3566 (cli.trend, "--trend"),
3567 (cli.save_snapshot.is_some(), "--save-snapshot"),
3568 (cli.coverage.is_some(), "--coverage"),
3569 (cli.coverage_root.is_some(), "--coverage-root"),
3570 (cli.include_entry_exports, "--include-entry-exports"),
3571 (cli.type_aware, "--type-aware"),
3572 (cli.no_type_aware, "--no-type-aware"),
3573 (!cli.type_aware_project.is_empty(), "--type-aware-project"),
3574 (cli.type_aware_require.is_some(), "--type-aware-require"),
3575 ]
3576 .into_iter()
3577 .find_map(|(used, flag)| used.then_some(flag))
3578}
3579
3580fn run_schema_command_if_requested(
3581 cli: &Cli,
3582 json_style: json_style::JsonStyle,
3583) -> Option<ExitCode> {
3584 match cli.command {
3585 Some(Command::Schema) => Some(schema::run_schema(json_style)),
3586 Some(Command::ConfigSchema) => Some(init::run_config_schema(json_style)),
3587 Some(Command::PluginSchema) => Some(init::run_plugin_schema(json_style)),
3588 Some(Command::RulePackSchema) => Some(init::run_rule_pack_schema(json_style)),
3589 _ => None,
3590 }
3591}
3592
3593fn regression_save_targets(cli: &Cli) -> (Option<std::path::PathBuf>, bool) {
3594 let save_file = cli.save_regression_baseline.as_ref().and_then(|opt| {
3595 opt.as_ref()
3596 .filter(|path| !path.is_empty())
3597 .map(std::path::PathBuf::from)
3598 });
3599 let save_to_config = cli.save_regression_baseline.is_some() && save_file.is_none();
3600 (save_file, save_to_config)
3601}
3602
3603fn dispatch_bare_command(dispatch: &DispatchContext<'_>) -> ExitCode {
3604 let cli = dispatch.cli;
3605 let (run_check, run_dupes, run_health) = combined::resolve_analyses(&cli.only, &cli.skip);
3606 let production = match dispatch.production_modes(
3607 cli.production_dead_code,
3608 cli.production_health,
3609 cli.production_dupes,
3610 ) {
3611 Ok(production) => production,
3612 Err(code) => return code,
3613 };
3614 let coverage_inputs = if run_health {
3619 match resolve_health_coverage_inputs(
3620 dispatch,
3621 cli.coverage.as_deref(),
3622 cli.coverage_root.as_deref(),
3623 ) {
3624 Ok(inputs) => inputs,
3625 Err(code) => return code,
3626 }
3627 } else {
3628 ResolvedHealthCoverageInputs::default()
3629 };
3630 run_bare_combined(
3631 dispatch,
3632 production,
3633 &coverage_inputs,
3634 BareAnalyses {
3635 run_check,
3636 run_dupes,
3637 run_health,
3638 },
3639 )
3640}
3641
3642#[derive(Clone, Copy)]
3644struct BareAnalyses {
3645 run_check: bool,
3646 run_dupes: bool,
3647 run_health: bool,
3648}
3649
3650fn run_bare_combined(
3653 dispatch: &DispatchContext<'_>,
3654 production: ProductionModes,
3655 coverage_inputs: &ResolvedHealthCoverageInputs,
3656 analyses: BareAnalyses,
3657) -> ExitCode {
3658 let cli = dispatch.cli;
3659 let (output, quiet, fail_on_issues) =
3660 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
3661 let scope = match crate::scope_path::resolve_command_scope(
3662 dispatch.root,
3663 dispatch.output,
3664 cli.path.clone(),
3665 ) {
3666 Ok(scope) => scope.map(|resolved| resolved.absolute),
3667 Err(code) => return code,
3668 };
3669 let scoped_run = scope.is_some();
3670 combined::run_combined(&combined::CombinedOptions {
3671 root: dispatch.root,
3672 config_path: &cli.config,
3673 output,
3674 json_style: dispatch.json_style,
3675 no_cache: cli.no_cache,
3676 threads: dispatch.threads,
3677 quiet,
3678 allow_remote_extends: cli.allow_remote_extends,
3679 fail_on_issues,
3680 sarif_file: cli.sarif_file.as_deref(),
3681 changed_since: cli.changed_since.as_deref(),
3682 churn_file: cli.churn_file.as_deref(),
3683 baseline: cli.baseline.as_deref(),
3684 save_baseline: cli.save_baseline.as_deref(),
3685 fail_on_stale_baseline: cli.fail_on_stale_baseline,
3686 production: cli.production,
3687 production_dead_code: Some(production.dead_code),
3688 production_health: Some(production.health),
3689 production_dupes: Some(production.dupes),
3690 workspace: cli.workspace.as_deref(),
3691 changed_workspaces: cli.changed_workspaces.as_deref(),
3692 group_by: cli.group_by,
3693 type_aware: cli.type_aware_override(),
3694 type_aware_projects: &cli.type_aware_project,
3695 type_aware_require: cli.type_aware_require.map(Into::into),
3696 explain: cli.explain,
3697 explain_skipped: cli.explain_skipped,
3698 performance: cli.performance,
3699 summary: cli.summary,
3700 run_check: analyses.run_check,
3701 run_dupes: analyses.run_dupes,
3702 run_health: analyses.run_health,
3703 dupes_mode: cli.dupes_mode,
3704 dupes_near: cli.dupes_near,
3705 dupes_threshold: cli.dupes_threshold,
3706 dupes_min_tokens: cli.dupes_min_tokens,
3707 dupes_min_lines: cli.dupes_min_lines,
3708 dupes_min_occurrences: cli.dupes_min_occurrences,
3709 dupes_skip_local: cli.dupes_skip_local,
3710 dupes_cross_language: cli.dupes_cross_language,
3711 dupes_ignore_imports: resolve_ignore_imports(
3712 cli.dupes_ignore_imports,
3713 cli.dupes_no_ignore_imports,
3714 ),
3715 score: cli.score || cli.trend,
3716 trend: cli.trend,
3717 save_snapshot: cli.save_snapshot.as_ref(),
3718 coverage: coverage_inputs.coverage.as_deref(),
3719 coverage_root: coverage_inputs.coverage_root.as_deref(),
3720 include_entry_exports: cli.include_entry_exports,
3721 scope,
3722 regression_opts: dispatch.regression_opts(
3723 cli.changed_since.is_some()
3724 || cli.workspace.is_some()
3725 || cli.changed_workspaces.is_some()
3726 || scoped_run,
3727 ),
3728 })
3729}
3730
3731#[allow(
3732 clippy::too_many_lines,
3733 reason = "the command router is intentionally an exhaustive top-level dispatch table"
3734)]
3735fn dispatch_subcommand(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3736 let cli = dispatch.cli;
3737 let root = dispatch.root;
3738 let output = dispatch.output;
3739 let quiet = dispatch.quiet;
3740 match command {
3741 check @ Command::Check { .. } => dispatch_check_command(check, dispatch),
3742 Command::Watch { no_clear } => dispatch_watch(dispatch, no_clear),
3743 Command::TypeAware { subcommand } => dispatch_type_aware_command(dispatch, subcommand),
3744 Command::Doctor => unreachable!("doctor bypasses the normal dispatch epilogue"),
3745 Command::SimilarCode {
3746 subcommand,
3747 threshold,
3748 min_lines,
3749 top,
3750 file,
3751 path,
3752 } => {
3753 let scope = match crate::scope_path::resolve_command_scope(
3754 dispatch.root,
3755 dispatch.output,
3756 path,
3757 ) {
3758 Ok(scope) => scope,
3759 Err(code) => return code,
3760 };
3761 similar_code_cli::run(similar_code_cli::SimilarCodeCliInput {
3762 root,
3763 config_path: cli.config.as_deref(),
3764 allow_remote_extends: cli.allow_remote_extends,
3765 no_cache: cli.no_cache,
3766 threads: dispatch.threads,
3767 changed_since: cli.changed_since.as_deref(),
3768 diff_file: cli.diff_file.as_deref(),
3769 workspace: cli.workspace.as_deref(),
3770 changed_workspaces: cli.changed_workspaces.as_deref(),
3771 explain: cli.explain,
3772 quiet,
3773 output,
3774 json_style: dispatch.json_style,
3775 threshold,
3776 min_lines,
3777 top,
3778 files: file,
3779 scope,
3780 subcommand,
3781 })
3782 }
3783 Command::Inspect {
3784 file,
3785 symbol,
3786 symbol_chain,
3787 churn,
3788 } => dispatch_inspect_command(dispatch, file, symbol, symbol_chain, churn),
3789 Command::Trace {
3790 symbol,
3791 path,
3792 callers,
3793 callees,
3794 depth,
3795 } => dispatch_trace_command(dispatch, symbol, &path, callers, callees, depth),
3796 Command::TraceError { trace_file } => {
3797 trace_error::run_trace_error(&trace_error::TraceErrorOptions {
3798 root: dispatch.root,
3799 config_path: &dispatch.cli.config,
3800 output: dispatch.output,
3801 json_style: dispatch.json_style,
3802 no_cache: dispatch.cli.no_cache,
3803 threads: dispatch.threads,
3804 quiet: dispatch.quiet,
3805 allow_remote_extends: dispatch.cli.allow_remote_extends,
3806 trace_file: trace_file.as_deref(),
3807 })
3808 }
3809 fix @ Command::Fix { .. } => dispatch_fix_command(&fix, dispatch),
3810 init @ Command::Init { .. } => dispatch_init_command(init, root, quiet),
3811 Command::Hooks { subcommand } => {
3812 run_hooks_command(root, subcommand, output, dispatch.json_style)
3813 }
3814 Command::Agent { subcommand } => dispatch_agent_command(dispatch, subcommand),
3815 Command::Ci { subcommand } => {
3816 ci::run(map_ci_subcommand(subcommand), output, dispatch.json_style)
3817 }
3818 Command::ConfigSchema => init::run_config_schema(dispatch.json_style),
3819 Command::PluginSchema => init::run_plugin_schema(dispatch.json_style),
3820 Command::PluginCheck => plugin_check::run_plugin_check(root, output, dispatch.json_style),
3821 Command::RulePackSchema => init::run_rule_pack_schema(dispatch.json_style),
3822 Command::RulePack { subcommand } => dispatch_rule_pack_command(dispatch, subcommand),
3823 Command::Guard { files } => dispatch_guard_command(dispatch, &files),
3824 Command::CiTemplate { subcommand } => dispatch_ci_template_command(subcommand),
3825 Command::Config { path } => config::run_config_with_options(config::RunConfigInput {
3826 root,
3827 explicit_config: cli.config.as_deref(),
3828 path_only: path,
3829 output,
3830 quiet,
3831 json_style: dispatch.json_style,
3832 load_options: fallow_config::ConfigLoadOptions {
3833 allow_remote_extends: cli.allow_remote_extends,
3834 },
3835 }),
3836 Command::Recommend => onboarding::run_recommend(root, output, dispatch.json_style),
3837 list @ (Command::Workspaces | Command::List { .. }) => {
3838 dispatch_list_command(&list, dispatch)
3839 }
3840 dupes @ Command::Dupes { .. } => dispatch_dupes_command(dupes, dispatch),
3841 health @ Command::Health { .. } => dispatch_health_command(health, dispatch),
3842 Command::Flags { top } => dispatch_flags_command(dispatch, top),
3843 Command::Suppressions { file } => dispatch_suppressions_command(dispatch, &file),
3844 Command::Explain { issue_type } => {
3845 explain::run_explain(&issue_type.join(" "), output, dispatch.json_style)
3846 }
3847 audit @ Command::Audit { .. } => dispatch_audit_command(audit, dispatch),
3848 Command::AuditCache { subcommand } => dispatch_audit_cache_command(dispatch, &subcommand),
3849 Command::DecisionSurface { max_decisions } => {
3850 dispatch_decision_surface(dispatch, max_decisions)
3851 }
3852 Command::Impact {
3853 subcommand,
3854 all,
3855 sort,
3856 limit,
3857 } => dispatch_impact(
3858 root,
3859 quiet,
3860 output,
3861 dispatch.json_style,
3862 subcommand,
3863 ImpactCrossRepoOpts { all, sort, limit },
3864 ),
3865 security @ Command::Security { .. } => dispatch_security_command(security, dispatch),
3866 Command::Viz {
3867 output: viz_output,
3868 no_open,
3869 viz_format,
3870 } => dispatch_viz(dispatch, viz_output.as_deref(), no_open, viz_format),
3871 Command::Report { from } => {
3872 cli_report::run_report(&from, output, root, cli.config.as_deref())
3873 }
3874 Command::Schema => unreachable!("handled above"),
3875 migrate @ Command::Migrate { .. } => dispatch_migrate_command(migrate, root),
3876 Command::License { subcommand } => {
3877 dispatch_license_command(subcommand, output, dispatch.json_style)
3878 }
3879 Command::Telemetry { .. } => unreachable!("handled before root validation"),
3880 Command::Coverage { subcommand } => dispatch_coverage_command(dispatch, &subcommand),
3881 setup_hooks @ Command::SetupHooks { .. } => {
3882 dispatch_setup_hooks_command(&setup_hooks, dispatch)
3883 }
3884 }
3885}
3886
3887fn dispatch_type_aware_command(
3888 dispatch: &DispatchContext<'_>,
3889 subcommand: TypeAwareCli,
3890) -> ExitCode {
3891 match subcommand {
3892 TypeAwareCli::Status => {
3893 let status = fallow_api::type_aware_status(dispatch.root);
3894 match dispatch.output {
3895 fallow_config::OutputFormat::Json => {
3896 let output = type_aware_status_output(dispatch.root, status);
3897 match fallow_output::serialize_type_aware_status_json_output(
3898 output,
3899 crate::output_runtime::current_root_envelope_mode(),
3900 ) {
3901 Ok(value) => match dispatch.json_style.serialize(&value) {
3902 Ok(json) => {
3903 crate::report::sink::outln!("{json}");
3904 ExitCode::SUCCESS
3905 }
3906 Err(error) => emit_error(
3907 &format!("failed to serialize type-aware status: {error}"),
3908 2,
3909 dispatch.output,
3910 ),
3911 },
3912 Err(error) => emit_error(
3913 &format!("failed to build type-aware status: {error}"),
3914 2,
3915 dispatch.output,
3916 ),
3917 }
3918 }
3919 fallow_config::OutputFormat::Human => {
3920 if status.available {
3921 crate::report::sink::outln!(
3922 "{}",
3923 report::human_status_line(
3924 report::HumanStatus::Ok,
3925 format_args!(
3926 "Type-aware companion: available ({}, protocol {}, TypeScript {})",
3927 status.package_version.as_deref().unwrap_or("unknown"),
3928 status.protocol_version,
3929 status.backend_version.as_deref().unwrap_or("unknown"),
3930 )
3931 )
3932 );
3933 } else {
3934 crate::report::sink::outln!(
3935 "{}",
3936 report::human_status_line(
3937 report::HumanStatus::Inactive,
3938 "Type-aware companion: unavailable"
3939 )
3940 );
3941 if let Some(remediation) = status.remediation {
3942 crate::report::sink::outln!(
3943 "{}",
3944 report::human_status_line(
3945 report::HumanStatus::Warning,
3946 format_args!("Action: {remediation}")
3947 )
3948 );
3949 }
3950 }
3951 ExitCode::SUCCESS
3952 }
3953 _ => emit_error(
3954 "type-aware status supports human and json output",
3955 2,
3956 dispatch.output,
3957 ),
3958 }
3959 }
3960 }
3961}
3962
3963fn type_aware_status_output(
3964 root: &Path,
3965 status: fallow_api::TypeAwareStatus,
3966) -> fallow_output::TypeAwareStatusOutput {
3967 let companion_path = status.companion_path.as_deref().map(|path| {
3968 if let Ok(relative) = path.strip_prefix(root)
3969 && !relative.as_os_str().is_empty()
3970 {
3971 relative.to_string_lossy().replace('\\', "/")
3972 } else {
3973 path.file_name()
3974 .unwrap_or(path.as_os_str())
3975 .to_string_lossy()
3976 .into_owned()
3977 }
3978 });
3979 let remediation = status.remediation.map(|message| {
3980 let without_root = message.replace(root.to_string_lossy().as_ref(), ".");
3981 status.companion_path.as_deref().map_or_else(
3982 || without_root.clone(),
3983 |path| {
3984 without_root.replace(
3985 path.to_string_lossy().as_ref(),
3986 companion_path.as_deref().unwrap_or("fallow-type-aware"),
3987 )
3988 },
3989 )
3990 });
3991 fallow_output::TypeAwareStatusOutput {
3992 schema_version: fallow_types::envelope::SchemaVersion(
3993 fallow_output::TYPE_AWARE_STATUS_SCHEMA_VERSION,
3994 ),
3995 version: fallow_types::envelope::ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
3996 available: status.available,
3997 discovery_source: status.discovery_source.map(str::to_string),
3998 companion_path,
3999 package_version: status.package_version,
4000 protocol_version: status.protocol_version,
4001 backend_family: status.backend_family,
4002 backend_version: status.backend_version,
4003 remediation,
4004 }
4005}
4006
4007fn dispatch_check_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4009 let filters = check_issue_filters(&command);
4010 let Command::Check {
4011 include_dupes,
4012 trace,
4013 trace_file,
4014 trace_dependency,
4015 impact_closure,
4016 symbol_impact,
4017 top,
4018 file,
4019 path,
4020 ..
4021 } = command
4022 else {
4023 unreachable!("check dispatcher only handles check commands");
4024 };
4025
4026 let scope = match crate::scope_path::resolve_command_scope(dispatch.root, dispatch.output, path)
4027 {
4028 Ok(scope) => scope.map(|resolved| resolved.absolute),
4029 Err(code) => return code,
4030 };
4031
4032 dispatch_check(
4033 dispatch,
4034 &CheckDispatchArgs {
4035 filters,
4036 trace_opts: TraceOptions {
4037 trace_export: trace,
4038 trace_file,
4039 trace_dependency,
4040 impact_closure,
4041 symbol_impact,
4042 performance: dispatch.cli.performance,
4043 },
4044 include_dupes,
4045 type_aware: dispatch.cli.type_aware_override(),
4046 type_aware_project: dispatch.cli.type_aware_project.clone(),
4047 type_aware_require: dispatch.cli.type_aware_require,
4048 top,
4049 file,
4050 scope,
4051 },
4052 )
4053}
4054
4055fn check_issue_filters(command: &Command) -> IssueFilters {
4060 check_issue_filters_framework(command, &check_issue_filters_core(command))
4061}
4062
4063fn check_issue_filters_core(command: &Command) -> IssueFilters {
4066 let Command::Check {
4067 unused_files,
4068 unused_exports,
4069 unused_deps,
4070 unused_types,
4071 private_type_leaks,
4072 unused_enum_members,
4073 unused_class_members,
4074 unresolved_imports,
4075 unlisted_deps,
4076 duplicate_exports,
4077 circular_deps,
4078 re_export_cycles,
4079 boundary_violations,
4080 policy_violations,
4081 stale_suppressions,
4082 ..
4083 } = command
4084 else {
4085 unreachable!("check filter builder only handles check commands");
4086 };
4087
4088 let mut filters = IssueFilters::default();
4089 for (flag, active) in [
4090 ("--unused-files", *unused_files),
4091 ("--unused-exports", *unused_exports),
4092 ("--unused-deps", *unused_deps),
4093 ("--unused-types", *unused_types),
4094 ("--private-type-leaks", *private_type_leaks),
4095 ("--unused-enum-members", *unused_enum_members),
4096 ("--unused-class-members", *unused_class_members),
4097 ("--unresolved-imports", *unresolved_imports),
4098 ("--unlisted-deps", *unlisted_deps),
4099 ("--duplicate-exports", *duplicate_exports),
4100 ("--circular-deps", *circular_deps),
4101 ("--re-export-cycles", *re_export_cycles),
4102 ("--boundary-violations", *boundary_violations),
4103 ("--policy-violations", *policy_violations),
4104 ("--stale-suppressions", *stale_suppressions),
4105 ] {
4106 enable_check_filter(&mut filters, flag, active);
4107 }
4108 filters
4109}
4110
4111fn check_issue_filters_framework(command: &Command, base: &IssueFilters) -> IssueFilters {
4114 let Command::Check {
4115 unused_store_members,
4116 unprovided_injects,
4117 unrendered_components,
4118 unused_component_props,
4119 unused_component_emits,
4120 unused_component_inputs,
4121 unused_component_outputs,
4122 unused_svelte_events,
4123 unused_server_actions,
4124 unused_load_data_keys,
4125 unused_catalog_entries,
4126 empty_catalog_groups,
4127 unresolved_catalog_references,
4128 unused_dependency_overrides,
4129 misconfigured_dependency_overrides,
4130 ..
4131 } = command
4132 else {
4133 unreachable!("check filter builder only handles check commands");
4134 };
4135
4136 let mut filters = base.clone();
4137 for (flag, active) in [
4138 ("--unused-store-members", *unused_store_members),
4139 ("--unprovided-injects", *unprovided_injects),
4140 ("--unrendered-components", *unrendered_components),
4141 ("--unused-component-props", *unused_component_props),
4142 ("--unused-component-emits", *unused_component_emits),
4143 ("--unused-component-inputs", *unused_component_inputs),
4144 ("--unused-component-outputs", *unused_component_outputs),
4145 ("--unused-svelte-events", *unused_svelte_events),
4146 ("--unused-server-actions", *unused_server_actions),
4147 ("--unused-load-data-keys", *unused_load_data_keys),
4148 ("--unused-catalog-entries", *unused_catalog_entries),
4149 ("--empty-catalog-groups", *empty_catalog_groups),
4150 (
4151 "--unresolved-catalog-references",
4152 *unresolved_catalog_references,
4153 ),
4154 (
4155 "--unused-dependency-overrides",
4156 *unused_dependency_overrides,
4157 ),
4158 (
4159 "--misconfigured-dependency-overrides",
4160 *misconfigured_dependency_overrides,
4161 ),
4162 ] {
4163 enable_check_filter(&mut filters, flag, active);
4164 }
4165 filters
4166}
4167
4168fn enable_check_filter(filters: &mut IssueFilters, flag: &str, active: bool) {
4169 if active {
4170 assert!(
4171 filters.enable_cli_filter_flag(flag),
4172 "check command uses unregistered dead-code filter flag {flag}"
4173 );
4174 }
4175}
4176
4177fn dispatch_inspect_command(
4178 dispatch: &DispatchContext<'_>,
4179 file: Option<String>,
4180 symbol: Option<String>,
4181 symbol_chain: bool,
4182 churn: bool,
4183) -> ExitCode {
4184 let target = match (file, symbol) {
4185 (Some(file), None) => inspect::InspectTarget::File { file },
4186 (None, Some(symbol)) => match selector::parse_file_symbol_selector(&symbol) {
4187 Some((file, export_name)) => inspect::InspectTarget::Symbol {
4188 file: file.to_string(),
4189 export_name: export_name.to_string(),
4190 },
4191 None => {
4192 return emit_error(
4193 "--symbol must be formatted as FILE:EXPORT",
4194 2,
4195 dispatch.output,
4196 );
4197 }
4198 },
4199 _ => {
4200 return emit_error(
4201 "inspect requires exactly one of --file or --symbol",
4202 2,
4203 dispatch.output,
4204 );
4205 }
4206 };
4207
4208 let churn_config = if churn {
4209 match load_config_for_analysis(
4210 dispatch.root,
4211 &dispatch.cli.config,
4212 ConfigLoadOptions {
4213 output: dispatch.output,
4214 no_cache: dispatch.cli.no_cache,
4215 threads: dispatch.threads,
4216 production_override: None,
4217 quiet: dispatch.quiet,
4218 allow_remote_extends: dispatch.cli.allow_remote_extends,
4219 },
4220 fallow_config::ProductionAnalysis::Health,
4221 ) {
4222 Ok(config) => Some(config),
4223 Err(code) => return code,
4224 }
4225 } else {
4226 None
4227 };
4228
4229 inspect::run_inspect(&inspect::InspectOptions {
4230 root: dispatch.root,
4231 config_path: dispatch.cli.config.as_ref(),
4232 output: dispatch.output,
4233 json_style: dispatch.json_style,
4234 no_cache: dispatch.cli.no_cache,
4235 no_production: dispatch.cli.no_production,
4236 max_file_size: dispatch.cli.max_file_size,
4237 threads: dispatch.threads,
4238 quiet: dispatch.quiet,
4239 production: dispatch.cli.production,
4240 workspace: dispatch.cli.workspace.as_ref(),
4241 target,
4242 churn_cache_dir: churn_config
4243 .as_ref()
4244 .map(|config| config.cache_dir.as_path()),
4245 symbol_chain,
4246 type_aware: dispatch.cli.type_aware_override(),
4247 type_aware_projects: &dispatch.cli.type_aware_project,
4248 type_aware_require: dispatch.cli.type_aware_require.map(Into::into),
4249 })
4250}
4251
4252fn dispatch_trace_command(
4253 dispatch: &DispatchContext<'_>,
4254 symbol: Option<String>,
4255 path: &[String],
4256 callers: bool,
4257 callees: bool,
4258 depth: Option<u32>,
4259) -> ExitCode {
4260 if let [from, to] = path {
4261 return trace_path::run_trace_path(&trace_path::TracePathOptions {
4262 root: dispatch.root,
4263 config_path: &dispatch.cli.config,
4264 output: dispatch.output,
4265 json_style: dispatch.json_style,
4266 no_cache: dispatch.cli.no_cache,
4267 threads: dispatch.threads,
4268 quiet: dispatch.quiet,
4269 allow_remote_extends: dispatch.cli.allow_remote_extends,
4270 from,
4271 to,
4272 });
4273 }
4274 let Some(symbol) = symbol else {
4275 return emit_error(
4276 "trace requires a FILE:SYMBOL target or --path <FROM> <TO>",
4277 2,
4278 dispatch.output,
4279 );
4280 };
4281 trace_chain::run_trace(&trace_chain::TraceChainOptions {
4282 root: dispatch.root,
4283 config_path: &dispatch.cli.config,
4284 output: dispatch.output,
4285 json_style: dispatch.json_style,
4286 no_cache: dispatch.cli.no_cache,
4287 threads: dispatch.threads,
4288 quiet: dispatch.quiet,
4289 allow_remote_extends: dispatch.cli.allow_remote_extends,
4290 target: symbol,
4291 callers,
4292 callees,
4293 depth: depth.unwrap_or(fallow_types::trace_chain::DEFAULT_TRACE_DEPTH),
4294 })
4295}
4296
4297fn dispatch_security_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4298 let Command::Security {
4299 subcommand,
4300 runtime_coverage,
4301 min_invocations_hot,
4302 file,
4303 gate,
4304 surface,
4305 path,
4306 } = command
4307 else {
4308 unreachable!("security dispatcher only handles security commands");
4309 };
4310
4311 let scope = match crate::scope_path::resolve_command_scope(dispatch.root, dispatch.output, path)
4312 {
4313 Ok(scope) => scope.map(|resolved| resolved.absolute),
4314 Err(code) => return code,
4315 };
4316
4317 let gate = gate.map(security::SecurityGateArg::into_mode);
4318 let cli = dispatch.cli;
4319 let (output, _quiet, fail_on_issues) =
4320 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
4321 let derived_flags = SecurityDerivedFlagState {
4322 output,
4323 json_style: dispatch.json_style,
4324 ci: cli.ci,
4325 fail_on_issues,
4326 sarif_file: cli.sarif_file.as_deref(),
4327 summary: cli.summary,
4328 explain: cli.explain,
4329 runtime_coverage: runtime_coverage.as_deref(),
4330 min_invocations_hot,
4331 file: file.as_slice(),
4332 gate,
4333 surface,
4334 };
4335 if let Some(code) = try_run_security_survivors(subcommand.as_ref(), &derived_flags) {
4336 return code;
4337 }
4338
4339 let scoped_files = scoped_security_files(&file, subcommand.as_ref());
4340 run_security_blind_spots_or_default(
4341 dispatch,
4342 &SecurityRunInputs {
4343 scoped_files: &scoped_files,
4344 subcommand: &subcommand,
4345 runtime_coverage: runtime_coverage.as_deref(),
4346 min_invocations_hot,
4347 gate,
4348 surface,
4349 scope,
4350 },
4351 &derived_flags,
4352 )
4353}
4354
4355struct SecurityRunInputs<'a> {
4358 scoped_files: &'a [PathBuf],
4359 subcommand: &'a Option<SecuritySubcommand>,
4360 runtime_coverage: Option<&'a Path>,
4361 min_invocations_hot: u64,
4362 gate: Option<security::SecurityGateMode>,
4363 surface: bool,
4364 scope: Option<PathBuf>,
4365}
4366
4367fn run_security_blind_spots_or_default(
4369 dispatch: &DispatchContext<'_>,
4370 inputs: &SecurityRunInputs<'_>,
4371 derived_flags: &SecurityDerivedFlagState<'_>,
4372) -> ExitCode {
4373 let cli = dispatch.cli;
4374 let (output, quiet, fail_on_issues) =
4375 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
4376 let opts = security::SecurityOptions {
4377 root: dispatch.root,
4378 config_path: &cli.config,
4379 output,
4380 json_style: dispatch.json_style,
4381 no_cache: cli.no_cache,
4382 threads: dispatch.threads,
4383 quiet,
4384 allow_remote_extends: cli.allow_remote_extends,
4385 fail_on_issues,
4386 sarif_file: cli.sarif_file.as_deref(),
4387 summary: cli.summary,
4388 changed_since: cli.changed_since.as_deref(),
4389 use_shared_diff_index: true,
4390 workspace: cli.workspace.as_deref(),
4391 changed_workspaces: cli.changed_workspaces.as_deref(),
4392 file: inputs.scoped_files,
4393 surface: inputs.surface,
4394 scope: inputs.scope.clone(),
4395 gate: inputs.gate,
4396 runtime_coverage: inputs.runtime_coverage,
4397 min_invocations_hot: inputs.min_invocations_hot,
4398 explain: cli.explain,
4399 };
4400 if matches!(
4401 inputs.subcommand,
4402 Some(SecuritySubcommand::BlindSpots { .. })
4403 ) {
4404 if let Some(code) = validate_security_blind_spots_flags(derived_flags) {
4405 return code;
4406 }
4407 security::run_blind_spots(&opts)
4408 } else {
4409 security::run(&opts)
4410 }
4411}
4412
4413fn try_run_security_survivors(
4416 subcommand: Option<&SecuritySubcommand>,
4417 flags: &SecurityDerivedFlagState<'_>,
4418) -> Option<ExitCode> {
4419 let Some(SecuritySubcommand::Survivors {
4420 candidates,
4421 verdicts,
4422 require_verdict_for_each_candidate,
4423 }) = subcommand
4424 else {
4425 return None;
4426 };
4427 if let Some(code) = validate_security_survivors_flags(flags) {
4428 return Some(code);
4429 }
4430 Some(security::run_survivors(
4431 &security::SecuritySurvivorsOptions {
4432 output: flags.output,
4433 json_style: flags.json_style,
4434 candidates,
4435 verdicts,
4436 require_verdict_for_each_candidate: *require_verdict_for_each_candidate,
4437 },
4438 ))
4439}
4440
4441fn scoped_security_files(
4443 file: &[PathBuf],
4444 subcommand: Option<&SecuritySubcommand>,
4445) -> Vec<PathBuf> {
4446 let mut scoped_files = file.to_vec();
4447 if let Some(SecuritySubcommand::BlindSpots {
4448 file: blind_spot_files,
4449 }) = subcommand
4450 {
4451 scoped_files.extend(blind_spot_files.iter().cloned());
4452 }
4453 scoped_files
4454}
4455
4456struct SecurityDerivedFlagState<'a> {
4457 output: fallow_config::OutputFormat,
4458 json_style: json_style::JsonStyle,
4459 ci: bool,
4460 fail_on_issues: bool,
4461 sarif_file: Option<&'a Path>,
4462 summary: bool,
4463 explain: bool,
4464 runtime_coverage: Option<&'a Path>,
4465 min_invocations_hot: u64,
4466 file: &'a [PathBuf],
4467 gate: Option<security::SecurityGateMode>,
4468 surface: bool,
4469}
4470
4471fn validate_security_survivors_flags(flags: &SecurityDerivedFlagState<'_>) -> Option<ExitCode> {
4472 let flag = if flags.ci {
4473 Some("--ci")
4474 } else if flags.fail_on_issues {
4475 Some("--fail-on-issues")
4476 } else if flags.sarif_file.is_some() {
4477 Some("--sarif-file")
4478 } else if flags.summary {
4479 Some("--summary")
4480 } else if flags.explain {
4481 Some("--explain")
4482 } else if flags.runtime_coverage.is_some() {
4483 Some("--runtime-coverage")
4484 } else if flags.min_invocations_hot != DEFAULT_MIN_INVOCATIONS_HOT {
4485 Some("--min-invocations-hot")
4486 } else if !flags.file.is_empty() {
4487 Some("--file")
4488 } else if flags.gate.is_some() {
4489 Some("--gate")
4490 } else if flags.surface {
4491 Some("--surface")
4492 } else {
4493 None
4494 }?;
4495 Some(emit_error(
4496 &format!("{flag} is not valid with `fallow security survivors`."),
4497 2,
4498 flags.output,
4499 ))
4500}
4501
4502fn validate_security_blind_spots_flags(flags: &SecurityDerivedFlagState<'_>) -> Option<ExitCode> {
4503 let flag = if flags.ci {
4504 Some("--ci")
4505 } else if flags.fail_on_issues {
4506 Some("--fail-on-issues")
4507 } else if flags.sarif_file.is_some() {
4508 Some("--sarif-file")
4509 } else if flags.summary {
4510 Some("--summary")
4511 } else if flags.explain {
4512 Some("--explain")
4513 } else if flags.runtime_coverage.is_some() {
4514 Some("--runtime-coverage")
4515 } else if flags.min_invocations_hot != DEFAULT_MIN_INVOCATIONS_HOT {
4516 Some("--min-invocations-hot")
4517 } else if flags.gate.is_some() {
4518 Some("--gate")
4519 } else if flags.surface {
4520 Some("--surface")
4521 } else {
4522 None
4523 }?;
4524 Some(emit_error(
4525 &format!("{flag} is not valid with `fallow security blind-spots`."),
4526 2,
4527 flags.output,
4528 ))
4529}
4530
4531fn dispatch_dupes_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4532 let Command::Dupes {
4533 mode,
4534 near,
4535 min_tokens,
4536 min_lines,
4537 min_occurrences,
4538 threshold,
4539 skip_local,
4540 cross_language,
4541 ignore_imports,
4542 no_ignore_imports,
4543 top,
4544 no_fragments,
4545 trace,
4546 path,
4547 } = command
4548 else {
4549 unreachable!("dupes dispatcher only handles dupes commands");
4550 };
4551
4552 let scope = match crate::scope_path::resolve_command_scope(dispatch.root, dispatch.output, path)
4553 {
4554 Ok(scope) => scope.map(|resolved| resolved.absolute),
4555 Err(code) => return code,
4556 };
4557
4558 dispatch_dupes(
4559 dispatch,
4560 &DupesDispatchArgs {
4561 mode,
4562 near,
4563 min_tokens,
4564 min_lines,
4565 min_occurrences,
4566 threshold,
4567 skip_local,
4568 cross_language,
4569 ignore_imports,
4570 no_ignore_imports,
4571 top,
4572 no_fragments,
4573 trace,
4574 scope,
4575 },
4576 )
4577}
4578
4579fn dispatch_agent_command(dispatch: &DispatchContext<'_>, subcommand: AgentCli) -> ExitCode {
4580 run_agent_command(
4581 dispatch.root,
4582 dispatch.cli.root.is_some(),
4583 subcommand,
4584 dispatch.output,
4585 dispatch.json_style,
4586 )
4587}
4588
4589fn dispatch_init_command(command: Command, root: &Path, quiet: bool) -> ExitCode {
4590 let Command::Init {
4591 toml,
4592 agents,
4593 hooks,
4594 branch,
4595 decline,
4596 } = command
4597 else {
4598 unreachable!("init dispatcher only handles init commands");
4599 };
4600
4601 init::run_init(&init::InitOptions {
4602 root,
4603 use_toml: toml,
4604 agents,
4605 hooks,
4606 branch: branch.as_deref(),
4607 decline,
4608 quiet,
4609 })
4610}
4611
4612fn dispatch_fix_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4613 let Command::Fix {
4614 dry_run,
4615 yes,
4616 no_create_config,
4617 path,
4618 } = command
4619 else {
4620 unreachable!("fix dispatcher only handles fix commands");
4621 };
4622
4623 let scope = match crate::scope_path::resolve_command_scope(
4624 dispatch.root,
4625 dispatch.output,
4626 path.clone(),
4627 ) {
4628 Ok(scope) => scope.map(|resolved| resolved.absolute),
4629 Err(code) => return code,
4630 };
4631
4632 dispatch_fix(
4633 dispatch,
4634 &FixDispatchArgs {
4635 dry_run: *dry_run,
4636 yes: *yes,
4637 no_create_config: *no_create_config,
4638 scope,
4639 },
4640 )
4641}
4642
4643fn dispatch_list_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4644 match command {
4645 Command::Workspaces => dispatch_list(dispatch, &ListDispatchArgs::workspaces()),
4646 Command::List {
4647 entry_points,
4648 files,
4649 plugins,
4650 boundaries,
4651 workspaces,
4652 path,
4653 } => {
4654 let scope = match crate::scope_path::resolve_command_scope(
4655 dispatch.root,
4656 dispatch.output,
4657 path.clone(),
4658 ) {
4659 Ok(scope) => scope.map(|resolved| resolved.absolute),
4660 Err(code) => return code,
4661 };
4662 dispatch_list(
4663 dispatch,
4664 &ListDispatchArgs {
4665 entry_points: *entry_points,
4666 files: *files,
4667 plugins: *plugins,
4668 boundaries: *boundaries,
4669 workspaces: *workspaces,
4670 scope,
4671 },
4672 )
4673 }
4674 _ => unreachable!("list dispatcher only handles list commands"),
4675 }
4676}
4677
4678fn dispatch_migrate_command(command: Command, root: &Path) -> ExitCode {
4679 let Command::Migrate {
4680 toml,
4681 jsonc,
4682 dry_run,
4683 from,
4684 } = command
4685 else {
4686 unreachable!("migrate dispatcher only handles migrate commands");
4687 };
4688
4689 migrate::run_migrate(root, toml, jsonc, dry_run, from.as_deref())
4690}
4691
4692fn dispatch_license_command(
4693 subcommand: LicenseCli,
4694 output: fallow_config::OutputFormat,
4695 json_style: json_style::JsonStyle,
4696) -> ExitCode {
4697 license::run(&map_license_subcommand(subcommand), output, json_style)
4698}
4699
4700fn dispatch_ci_template_command(subcommand: CiTemplateCli) -> ExitCode {
4701 match subcommand {
4702 CiTemplateCli::Gitlab { vendor, force } => {
4703 ci_template::run_gitlab_template(&ci_template::GitlabTemplateOptions {
4704 vendor_dir: vendor,
4705 force,
4706 })
4707 }
4708 }
4709}
4710
4711fn dispatch_coverage_command(dispatch: &DispatchContext<'_>, subcommand: &CoverageCli) -> ExitCode {
4712 let cli = dispatch.cli;
4713 coverage::run(
4714 map_coverage_subcommand(subcommand, cli.explain),
4715 &coverage::RunContext {
4716 root: dispatch.root,
4717 config_path: &cli.config,
4718 output: dispatch.output,
4719 json_style: dispatch.json_style,
4720 quiet: dispatch.quiet,
4721 no_cache: cli.no_cache,
4722 threads: dispatch.threads,
4723 explain: cli.explain,
4724 allow_remote_extends: cli.allow_remote_extends,
4725 },
4726 )
4727}
4728
4729fn dispatch_health_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4730 let Command::Health {
4731 max_cyclomatic,
4732 max_cognitive,
4733 max_crap,
4734 top,
4735 sort,
4736 complexity,
4737 complexity_breakdown,
4738 file_scores,
4739 coverage_gaps,
4740 hotspots,
4741 ownership,
4742 ownership_emails,
4743 targets,
4744 type_coupling,
4745 css,
4746 effort,
4747 score,
4748 min_score,
4749 min_severity,
4750 report_only,
4751 since,
4752 min_commits,
4753 save_snapshot,
4754 trend,
4755 coverage,
4756 coverage_root,
4757 runtime_coverage,
4758 min_invocations_hot,
4759 min_observation_volume,
4760 low_traffic_threshold,
4761 path,
4762 } = command
4763 else {
4764 unreachable!("health dispatcher only handles health commands");
4765 };
4766
4767 let scope = match crate::scope_path::resolve_command_scope(dispatch.root, dispatch.output, path)
4768 {
4769 Ok(scope) => scope.map(|resolved| resolved.absolute),
4770 Err(code) => return code,
4771 };
4772
4773 let ownership = ownership || ownership_emails.is_some();
4774 let hotspots = hotspots || ownership;
4775 let args = HealthDispatchArgs {
4776 max_cyclomatic,
4777 max_cognitive,
4778 max_crap,
4779 top,
4780 sort,
4781 complexity,
4782 complexity_breakdown,
4783 file_scores,
4784 coverage_gaps,
4785 hotspots,
4786 ownership,
4787 ownership_emails: ownership_emails.map(EmailModeArg::to_config),
4788 targets,
4789 type_coupling,
4790 css,
4791 effort,
4792 score,
4793 min_score,
4794 min_severity: min_severity.map(HealthSeverityCli::to_health_severity),
4795 report_only,
4796 since: since.as_deref(),
4797 min_commits,
4798 save_snapshot: save_snapshot.as_ref(),
4799 trend,
4800 fail_on_stale_baseline: dispatch.cli.fail_on_stale_baseline,
4801 coverage: coverage.as_deref(),
4802 coverage_root: coverage_root.as_deref(),
4803 runtime_coverage: runtime_coverage.as_deref(),
4804 min_invocations_hot,
4805 min_observation_volume,
4806 low_traffic_threshold,
4807 scope,
4808 };
4809 dispatch_health(dispatch, &args)
4810}
4811
4812fn dispatch_setup_hooks_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4813 let Command::SetupHooks {
4814 agent,
4815 dry_run,
4816 force,
4817 user,
4818 gitignore_claude,
4819 uninstall,
4820 } = command
4821 else {
4822 unreachable!("setup-hooks dispatcher only handles setup-hooks commands");
4823 };
4824
4825 eprintln!(
4826 "warning: `fallow setup-hooks` is deprecated and will be removed in the next major; use `fallow agent install` or `fallow hooks install --target agent`."
4827 );
4828 setup_hooks::run_setup_hooks(&setup_hooks::SetupHooksOptions {
4829 root: dispatch.root,
4830 agent: *agent,
4831 dry_run: *dry_run,
4832 force: *force,
4833 user: *user,
4834 gitignore_claude: *gitignore_claude,
4835 uninstall: *uninstall,
4836 })
4837}
4838
4839fn dispatch_audit_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4840 let Command::Audit {
4841 production_dead_code,
4842 production_health,
4843 production_dupes,
4844 dead_code_baseline,
4845 health_baseline,
4846 dupes_baseline,
4847 max_crap,
4848 coverage,
4849 coverage_root,
4850 no_css,
4851 css_deep,
4852 no_css_deep,
4853 gate,
4854 runtime_coverage,
4855 min_invocations_hot,
4856 gate_marker,
4857 brief,
4858 max_decisions,
4859 walkthrough_guide,
4860 walkthrough_file,
4861 walkthrough,
4862 mark_viewed,
4863 show_cleared,
4864 show_deprioritized,
4865 path,
4866 } = command
4867 else {
4868 unreachable!("audit dispatcher only handles audit commands");
4869 };
4870
4871 let brief = brief || walkthrough_guide || walkthrough || walkthrough_file.is_some();
4874
4875 let scope = match crate::scope_path::resolve_command_scope(dispatch.root, dispatch.output, path)
4876 {
4877 Ok(scope) => scope.map(|resolved| resolved.absolute),
4878 Err(code) => return code,
4879 };
4880
4881 dispatch_audit(
4882 dispatch,
4883 &AuditDispatchArgs {
4884 production_dead_code,
4885 production_health,
4886 production_dupes,
4887 dead_code_baseline,
4888 health_baseline,
4889 dupes_baseline,
4890 max_crap,
4891 coverage,
4892 coverage_root,
4893 no_css,
4894 css_deep,
4895 no_css_deep,
4896 gate,
4897 runtime_coverage,
4898 min_invocations_hot,
4899 gate_marker,
4900 brief,
4901 max_decisions,
4902 walkthrough_guide,
4903 walkthrough_file,
4904 walkthrough,
4905 mark_viewed,
4906 show_cleared,
4907 show_deprioritized,
4908 scope,
4909 },
4910 )
4911}
4912
4913fn dispatch_audit_cache_command(
4914 dispatch: &DispatchContext<'_>,
4915 subcommand: &AuditCacheCli,
4916) -> ExitCode {
4917 match subcommand {
4918 AuditCacheCli::Remove { dry_run, yes } => {
4919 if !*dry_run && !*yes && !std::io::stdin().is_terminal() {
4920 return emit_error(
4921 "audit-cache remove requires --yes (or --force) in non-interactive environments. Use --dry-run to preview removal first, then pass --yes to confirm.",
4922 2,
4923 dispatch.output,
4924 );
4925 }
4926 match base_worktree::remove_reusable_audit_caches(dispatch.root, *dry_run) {
4927 Ok(report) => {
4928 let action = if *dry_run { "would remove" } else { "removed" };
4929 if matches!(dispatch.output, fallow_config::OutputFormat::Json) {
4930 let value = serde_json::json!({
4931 "kind": "audit-cache-remove",
4932 "schema_version": 1,
4933 "command": "audit-cache remove",
4934 "root": dispatch.root,
4935 "dry_run": report.dry_run,
4936 "found": report.found,
4937 "would_remove": report.found.saturating_sub(report.skipped),
4938 "removed": report.removed,
4939 "skipped": report.skipped,
4940 "complete": report.skipped == 0,
4941 });
4942 let output_code = report::emit_report_json(
4943 &value,
4944 "audit cache removal",
4945 dispatch.json_style,
4946 );
4947 if output_code != ExitCode::SUCCESS {
4948 return output_code;
4949 }
4950 } else if !dispatch.quiet {
4951 println!(
4952 "audit cache: {action} {}, skipped {} for {}",
4953 if *dry_run {
4954 report.found.saturating_sub(report.skipped)
4955 } else {
4956 report.removed
4957 },
4958 report.skipped,
4959 dispatch.root.display(),
4960 );
4961 }
4962 if report.skipped == 0 {
4963 ExitCode::SUCCESS
4964 } else {
4965 ExitCode::from(2)
4966 }
4967 }
4968 Err(error) => emit_error(
4969 &format!(
4970 "failed to remove audit caches for {}: {error}",
4971 dispatch.root.display()
4972 ),
4973 2,
4974 dispatch.output,
4975 ),
4976 }
4977 }
4978 AuditCacheCli::Prune {
4979 dry_run,
4980 max_age_days,
4981 } => audit_cache_prune::run_audit_cache_prune(&audit_cache_prune::AuditCachePruneOptions {
4982 root: dispatch.root,
4983 config_path: dispatch.cli.config.as_ref(),
4984 allow_remote_extends: dispatch.cli.allow_remote_extends,
4985 dry_run: *dry_run,
4986 max_age_days: *max_age_days,
4987 output: dispatch.output,
4988 json_style: dispatch.json_style,
4989 quiet: dispatch.quiet,
4990 }),
4991 }
4992}
4993
4994fn dispatch_flags_command(dispatch: &DispatchContext<'_>, top: Option<usize>) -> ExitCode {
4995 let cli = dispatch.cli;
4996 let root = dispatch.root;
4997 let output = dispatch.output;
4998 let quiet = dispatch.quiet;
4999 let threads = dispatch.threads;
5000 let production = match resolve_production_modes(cli, root, output, false, false, false) {
5001 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::DeadCode),
5002 Err(code) => return code,
5003 };
5004 flags::run_flags(&flags::FlagsOptions {
5005 root,
5006 config_path: &cli.config,
5007 output,
5008 json_style: dispatch.json_style,
5009 no_cache: cli.no_cache,
5010 threads,
5011 quiet,
5012 allow_remote_extends: cli.allow_remote_extends,
5013 production,
5014 workspace: cli.workspace.as_deref(),
5015 changed_workspaces: cli.changed_workspaces.as_deref(),
5016 changed_since: cli.changed_since.as_deref(),
5017 explain: cli.explain,
5018 top,
5019 })
5020}
5021
5022fn dispatch_suppressions_command(
5023 dispatch: &DispatchContext<'_>,
5024 file: &[std::path::PathBuf],
5025) -> ExitCode {
5026 let cli = dispatch.cli;
5027 let root = dispatch.root;
5028 let output = dispatch.output;
5029 let production = match resolve_production_modes(cli, root, output, false, false, false) {
5030 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::DeadCode),
5031 Err(code) => return code,
5032 };
5033 suppressions::run_suppressions(&suppressions::SuppressionsOptions {
5034 root,
5035 config_path: &cli.config,
5036 output,
5037 json_style: dispatch.json_style,
5038 no_cache: cli.no_cache,
5039 threads: dispatch.threads,
5040 quiet: dispatch.quiet,
5041 allow_remote_extends: cli.allow_remote_extends,
5042 production,
5043 workspace: cli.workspace.as_deref(),
5044 changed_workspaces: cli.changed_workspaces.as_deref(),
5045 changed_since: cli.changed_since.as_deref(),
5046 file,
5047 })
5048}
5049
5050fn dispatch_guard_command(dispatch: &DispatchContext<'_>, files: &[String]) -> ExitCode {
5051 guard::run_guard(&guard::GuardOptions {
5052 root: dispatch.root,
5053 config_path: &dispatch.cli.config,
5054 output: dispatch.output,
5055 json_style: dispatch.json_style,
5056 quiet: dispatch.quiet,
5057 allow_remote_extends: dispatch.cli.allow_remote_extends,
5058 files,
5059 })
5060}
5061
5062fn dispatch_rule_pack_command(dispatch: &DispatchContext<'_>, subcommand: RulePackCli) -> ExitCode {
5063 let ctx = rule_pack::RulePackContext {
5064 root: dispatch.root,
5065 config_path: &dispatch.cli.config,
5066 output: dispatch.output,
5067 json_style: dispatch.json_style,
5068 quiet: dispatch.quiet,
5069 no_cache: dispatch.cli.no_cache,
5070 threads: Some(dispatch.threads),
5071 allow_remote_extends: dispatch.cli.allow_remote_extends,
5072 };
5073 rule_pack::run(&map_rule_pack_subcommand(subcommand), &ctx)
5074}
5075
5076fn map_rule_pack_subcommand(subcommand: RulePackCli) -> rule_pack::RulePackSubcommand {
5077 match subcommand {
5078 RulePackCli::Init {
5079 name,
5080 template,
5081 dir,
5082 no_config,
5083 } => rule_pack::RulePackSubcommand::Init(rule_pack::InitArgs {
5084 name,
5085 template,
5086 dir,
5087 no_config,
5088 }),
5089 RulePackCli::List => rule_pack::RulePackSubcommand::List,
5090 RulePackCli::Test { pack } => {
5091 rule_pack::RulePackSubcommand::Test(rule_pack::TestArgs { pack })
5092 }
5093 RulePackCli::Schema => rule_pack::RulePackSubcommand::Schema,
5094 }
5095}
5096
5097fn map_license_subcommand(sub: LicenseCli) -> license::LicenseSubcommand {
5098 match sub {
5099 LicenseCli::Activate {
5100 jwt,
5101 from_file,
5102 stdin,
5103 trial,
5104 email,
5105 } => license::LicenseSubcommand::Activate(license::ActivateArgs {
5106 raw_jwt: jwt,
5107 from_file,
5108 from_stdin: stdin,
5109 trial,
5110 email,
5111 }),
5112 LicenseCli::Status => license::LicenseSubcommand::Status,
5113 LicenseCli::Refresh { api_key } => {
5114 license::LicenseSubcommand::Refresh(license::RefreshArgs { api_key })
5115 }
5116 LicenseCli::Deactivate => license::LicenseSubcommand::Deactivate,
5117 }
5118}
5119
5120fn map_telemetry_subcommand(sub: TelemetryCli) -> telemetry::TelemetryCommand {
5121 match sub {
5122 TelemetryCli::Status => telemetry::TelemetryCommand::Status,
5123 TelemetryCli::Enable => telemetry::TelemetryCommand::Enable,
5124 TelemetryCli::Disable => telemetry::TelemetryCommand::Disable,
5125 TelemetryCli::Inspect { example } => telemetry::TelemetryCommand::Inspect { example },
5126 }
5127}
5128
5129fn map_ci_subcommand(sub: CiCli) -> ci::CiCommand {
5130 match sub {
5131 command @ CiCli::PlanPrComment { .. } => map_ci_plan_pr_comment(command),
5132 command @ CiCli::PostPrComment { .. } => map_ci_post_pr_comment(command),
5133 command @ CiCli::PostReview { .. } => map_ci_post_review(command),
5134 command @ CiCli::PostCheckRun { .. } => map_ci_post_check_run(command),
5135 command @ CiCli::ReconcileReview { .. } => map_ci_reconcile_review(command),
5136 }
5137}
5138
5139fn map_ci_plan_pr_comment(command: CiCli) -> ci::CiCommand {
5140 let CiCli::PlanPrComment {
5141 body,
5142 marker_id,
5143 clean,
5144 existing_comment_id,
5145 existing_body,
5146 } = command
5147 else {
5148 unreachable!("ci plan-pr-comment mapper called with different variant");
5149 };
5150
5151 ci::CiCommand::PlanPrComment {
5152 body,
5153 marker_id,
5154 clean,
5155 existing_comment_id,
5156 existing_body,
5157 }
5158}
5159
5160fn map_ci_post_pr_comment(command: CiCli) -> ci::CiCommand {
5161 let CiCli::PostPrComment {
5162 provider,
5163 pr,
5164 mr,
5165 body,
5166 envelope,
5167 marker_id,
5168 clean,
5169 repo,
5170 project_id,
5171 api_url,
5172 dry_run,
5173 } = command
5174 else {
5175 unreachable!("ci post-pr-comment mapper called with different variant");
5176 };
5177
5178 ci::CiCommand::PostPrComment {
5179 provider: map_ci_provider(provider),
5180 target: pr.or(mr),
5181 body,
5182 envelope,
5183 marker_id,
5184 clean,
5185 repo,
5186 project_id,
5187 api_url,
5188 dry_run,
5189 }
5190}
5191
5192fn map_ci_post_review(command: CiCli) -> ci::CiCommand {
5193 let CiCli::PostReview {
5194 provider,
5195 pr,
5196 mr,
5197 envelope,
5198 repo,
5199 project_id,
5200 api_url,
5201 dry_run,
5202 } = command
5203 else {
5204 unreachable!("ci post-review mapper called with different variant");
5205 };
5206
5207 ci::CiCommand::PostReview {
5208 provider: map_ci_provider(provider),
5209 target: pr.or(mr),
5210 envelope,
5211 repo,
5212 project_id,
5213 api_url,
5214 dry_run,
5215 }
5216}
5217
5218fn map_ci_post_check_run(command: CiCli) -> ci::CiCommand {
5219 let CiCli::PostCheckRun {
5220 provider,
5221 decision,
5222 repo,
5223 head_sha,
5224 api_url,
5225 split_gates,
5226 dry_run,
5227 } = command
5228 else {
5229 unreachable!("ci post-check-run mapper called with different variant");
5230 };
5231
5232 ci::CiCommand::PostCheckRun {
5233 provider: map_ci_provider(provider),
5234 decision,
5235 repo,
5236 head_sha,
5237 api_url,
5238 split_gates,
5239 dry_run,
5240 }
5241}
5242
5243fn map_ci_reconcile_review(command: CiCli) -> ci::CiCommand {
5244 let CiCli::ReconcileReview {
5245 provider,
5246 pr,
5247 mr,
5248 envelope,
5249 repo,
5250 project_id,
5251 api_url,
5252 dry_run,
5253 } = command
5254 else {
5255 unreachable!("ci reconcile-review mapper called with different variant");
5256 };
5257
5258 ci::CiCommand::ReconcileReview {
5259 provider: map_ci_provider(provider),
5260 target: pr.or(mr),
5261 envelope,
5262 repo,
5263 project_id,
5264 api_url,
5265 dry_run,
5266 }
5267}
5268
5269fn map_ci_provider(provider: CiProviderArg) -> ci::CiProvider {
5270 match provider {
5271 CiProviderArg::Github => ci::CiProvider::Github,
5272 CiProviderArg::Gitlab => ci::CiProvider::Gitlab,
5273 }
5274}
5275
5276fn map_coverage_subcommand(sub: &CoverageCli, explain: bool) -> coverage::CoverageSubcommand {
5277 match sub {
5278 CoverageCli::Setup {
5279 yes,
5280 non_interactive,
5281 json,
5282 } => map_coverage_setup(*yes, *non_interactive, *json, explain),
5283 CoverageCli::Analyze { .. } => map_coverage_analyze(sub),
5284 CoverageCli::UploadInventory { .. } => map_coverage_upload_inventory(sub),
5285 CoverageCli::UploadSourceMaps { .. } => map_coverage_upload_source_maps(sub),
5286 CoverageCli::UploadStaticFindings { .. } => map_coverage_upload_static_findings(sub),
5287 }
5288}
5289
5290fn map_coverage_setup(
5291 yes: bool,
5292 non_interactive: bool,
5293 json: bool,
5294 explain: bool,
5295) -> coverage::CoverageSubcommand {
5296 coverage::CoverageSubcommand::Setup(coverage::SetupArgs {
5297 yes,
5298 non_interactive: non_interactive || json,
5299 json,
5300 explain,
5301 })
5302}
5303
5304fn map_coverage_analyze(sub: &CoverageCli) -> coverage::CoverageSubcommand {
5305 let CoverageCli::Analyze {
5306 runtime_coverage,
5307 cloud,
5308 api_key,
5309 api_endpoint,
5310 repo,
5311 project_id,
5312 coverage_period,
5313 environment,
5314 commit_sha,
5315 production,
5316 min_invocations_hot,
5317 min_observation_volume,
5318 low_traffic_threshold,
5319 top,
5320 blast_radius,
5321 importance,
5322 debug_unmatched,
5323 } = sub
5324 else {
5325 unreachable!("coverage analyze mapper called with non-analyze variant");
5326 };
5327 coverage::CoverageSubcommand::Analyze(coverage::AnalyzeArgs {
5328 runtime_coverage: runtime_coverage.clone(),
5329 cloud: *cloud,
5330 api_key: api_key.clone(),
5331 api_endpoint: api_endpoint.clone(),
5332 repo: repo.clone(),
5333 project_id: project_id.clone(),
5334 coverage_period: *coverage_period,
5335 environment: environment.clone(),
5336 commit_sha: commit_sha.clone(),
5337 production: *production,
5338 min_invocations_hot: *min_invocations_hot,
5339 min_observation_volume: *min_observation_volume,
5340 low_traffic_threshold: *low_traffic_threshold,
5341 top: *top,
5342 blast_radius: *blast_radius,
5343 importance: *importance,
5344 debug_unmatched: *debug_unmatched,
5345 })
5346}
5347
5348fn map_coverage_upload_inventory(sub: &CoverageCli) -> coverage::CoverageSubcommand {
5349 let CoverageCli::UploadInventory {
5350 api_key,
5351 api_endpoint,
5352 project_id,
5353 git_sha,
5354 allow_dirty,
5355 exclude_paths,
5356 path_prefix,
5357 dry_run,
5358 with_callers,
5359 ignore_upload_errors,
5360 } = sub
5361 else {
5362 unreachable!("coverage inventory mapper called with non-inventory variant");
5363 };
5364 coverage::CoverageSubcommand::UploadInventory(coverage::UploadInventoryArgs {
5365 api_key: api_key.clone(),
5366 api_endpoint: api_endpoint.clone(),
5367 project_id: project_id.clone(),
5368 git_sha: git_sha.clone(),
5369 allow_dirty: *allow_dirty,
5370 exclude_paths: exclude_paths.clone(),
5371 path_prefix: path_prefix.clone(),
5372 dry_run: *dry_run,
5373 with_callers: *with_callers,
5374 ignore_upload_errors: *ignore_upload_errors,
5375 })
5376}
5377
5378fn map_coverage_upload_source_maps(sub: &CoverageCli) -> coverage::CoverageSubcommand {
5379 let CoverageCli::UploadSourceMaps {
5380 dir,
5381 include,
5382 exclude,
5383 repo,
5384 git_sha,
5385 endpoint,
5386 strip_path,
5387 dry_run,
5388 concurrency,
5389 fail_fast,
5390 } = sub
5391 else {
5392 unreachable!("coverage source-map mapper called with non-source-map variant");
5393 };
5394 coverage::CoverageSubcommand::UploadSourceMaps(coverage::UploadSourceMapsArgs {
5395 dir: dir.clone(),
5396 include: include.clone(),
5397 exclude: exclude.clone(),
5398 repo: repo.clone(),
5399 git_sha: git_sha.clone(),
5400 endpoint: endpoint.clone(),
5401 strip_path: *strip_path,
5402 dry_run: *dry_run,
5403 concurrency: *concurrency,
5404 fail_fast: *fail_fast,
5405 })
5406}
5407
5408fn map_coverage_upload_static_findings(sub: &CoverageCli) -> coverage::CoverageSubcommand {
5409 let CoverageCli::UploadStaticFindings {
5410 api_key,
5411 api_endpoint,
5412 project_id,
5413 git_sha,
5414 allow_dirty,
5415 dry_run,
5416 ignore_upload_errors,
5417 } = sub
5418 else {
5419 unreachable!("coverage static-findings mapper called with non-static variant");
5420 };
5421 coverage::CoverageSubcommand::UploadStaticFindings(coverage::UploadStaticFindingsArgs {
5422 api_key: api_key.clone(),
5423 api_endpoint: api_endpoint.clone(),
5424 project_id: project_id.clone(),
5425 git_sha: git_sha.clone(),
5426 allow_dirty: *allow_dirty,
5427 dry_run: *dry_run,
5428 ignore_upload_errors: *ignore_upload_errors,
5429 })
5430}
5431
5432struct CheckDispatchArgs {
5433 filters: IssueFilters,
5434 trace_opts: TraceOptions,
5435 include_dupes: bool,
5436 type_aware: Option<bool>,
5437 type_aware_project: Vec<std::path::PathBuf>,
5438 type_aware_require: Option<TypeAwareRequireArg>,
5439 top: Option<usize>,
5440 file: Vec<std::path::PathBuf>,
5441 scope: Option<std::path::PathBuf>,
5442}
5443
5444#[derive(Clone)]
5445struct ListDispatchArgs {
5446 entry_points: bool,
5447 files: bool,
5448 plugins: bool,
5449 boundaries: bool,
5450 workspaces: bool,
5451 scope: Option<std::path::PathBuf>,
5452}
5453
5454impl ListDispatchArgs {
5455 fn workspaces() -> Self {
5456 Self {
5457 entry_points: false,
5458 files: false,
5459 plugins: false,
5460 boundaries: false,
5461 workspaces: true,
5462 scope: None,
5463 }
5464 }
5465}
5466
5467fn dispatch_viz(
5468 dispatch: &DispatchContext<'_>,
5469 output_path: Option<&std::path::Path>,
5470 no_open: bool,
5471 format: viz::VizFormat,
5472) -> ExitCode {
5473 let cli = dispatch.cli;
5474 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
5475 Ok(production) => production,
5476 Err(code) => return code,
5477 };
5478 viz::run_viz(&viz::VizOptions {
5479 root: dispatch.root,
5480 config_path: &cli.config,
5481 no_cache: cli.no_cache,
5482 threads: dispatch.threads,
5483 quiet: dispatch.quiet,
5484 production,
5485 allow_remote_extends: cli.allow_remote_extends,
5486 output_path,
5487 no_open,
5488 format,
5489 })
5490}
5491
5492fn dispatch_watch(dispatch: &DispatchContext<'_>, no_clear: bool) -> ExitCode {
5493 let cli = dispatch.cli;
5494 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
5495 Ok(production) => production,
5496 Err(code) => return code,
5497 };
5498 watch::run_watch(&watch::WatchOptions {
5499 root: dispatch.root,
5500 config_path: &cli.config,
5501 output: dispatch.output,
5502 json_style: dispatch.json_style,
5503 no_cache: cli.no_cache,
5504 threads: dispatch.threads,
5505 quiet: dispatch.quiet,
5506 allow_remote_extends: cli.allow_remote_extends,
5507 production,
5508 clear_screen: !no_clear,
5509 explain: cli.explain,
5510 include_entry_exports: cli.include_entry_exports,
5511 type_aware: cli.type_aware_override(),
5512 type_aware_projects: &cli.type_aware_project,
5513 type_aware_require: cli.type_aware_require.map(Into::into),
5514 })
5515}
5516
5517struct FixDispatchArgs {
5518 dry_run: bool,
5519 yes: bool,
5520 no_create_config: bool,
5521 scope: Option<std::path::PathBuf>,
5522}
5523
5524fn dispatch_fix(dispatch: &DispatchContext<'_>, args: &FixDispatchArgs) -> ExitCode {
5525 let cli = dispatch.cli;
5526 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
5527 Ok(production) => production,
5528 Err(code) => return code,
5529 };
5530 fix::run_fix(&fix::FixOptions {
5531 root: dispatch.root,
5532 config_path: &cli.config,
5533 output: dispatch.output,
5534 json_style: dispatch.json_style,
5535 no_cache: cli.no_cache,
5536 threads: dispatch.threads,
5537 quiet: dispatch.quiet,
5538 emit_output: true,
5539 allow_remote_extends: cli.allow_remote_extends,
5540 dry_run: args.dry_run,
5541 yes: args.yes,
5542 production,
5543 no_create_config: args.no_create_config,
5544 type_aware: cli.type_aware_override(),
5545 type_aware_projects: &cli.type_aware_project,
5546 type_aware_require: cli.type_aware_require.map(Into::into),
5547 scope: args.scope.clone(),
5548 })
5549}
5550
5551fn dispatch_list(dispatch: &DispatchContext<'_>, args: &ListDispatchArgs) -> ExitCode {
5552 let cli = dispatch.cli;
5553 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
5554 Ok(production) => production,
5555 Err(code) => return code,
5556 };
5557 list::run_list(&ListOptions {
5558 root: dispatch.root,
5559 config_path: &cli.config,
5560 output: dispatch.output,
5561 json_style: dispatch.json_style,
5562 threads: dispatch.threads,
5563 no_cache: cli.no_cache,
5564 entry_points: args.entry_points,
5565 files: args.files,
5566 plugins: args.plugins,
5567 boundaries: args.boundaries,
5568 workspaces: args.workspaces,
5569 production,
5570 allow_remote_extends: cli.allow_remote_extends,
5571 scope: args.scope.clone(),
5572 })
5573}
5574
5575fn dispatch_check(dispatch: &DispatchContext<'_>, args: &CheckDispatchArgs) -> ExitCode {
5576 let cli = dispatch.cli;
5577 let (output, quiet, fail_on_issues) =
5578 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
5579 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
5580 Ok(production) => production,
5581 Err(code) => return code,
5582 };
5583 if let Some(code) = validate_type_aware_check_options(dispatch, args) {
5584 return code;
5585 }
5586 check::run_check(&CheckOptions {
5587 root: dispatch.root,
5588 config_path: &cli.config,
5589 output,
5590 json_style: dispatch.json_style,
5591 no_cache: cli.no_cache,
5592 threads: dispatch.threads,
5593 quiet,
5594 allow_remote_extends: cli.allow_remote_extends,
5595 fail_on_issues,
5596 filters: &args.filters,
5597 changed_since: cli.changed_since.as_deref(),
5598 diff_index: None,
5599 use_shared_diff_index: true,
5600 baseline: cli.baseline.as_deref(),
5601 save_baseline: cli.save_baseline.as_deref(),
5602 fail_on_stale_baseline: cli.fail_on_stale_baseline,
5603 sarif_file: cli.sarif_file.as_deref(),
5604 production,
5605 production_override: Some(production),
5606 workspace: cli.workspace.as_deref(),
5607 changed_workspaces: cli.changed_workspaces.as_deref(),
5608 group_by: cli.group_by,
5609 include_dupes: args.include_dupes,
5610 type_aware: args.type_aware,
5611 type_aware_config_override: None,
5612 type_aware_projects: &args.type_aware_project,
5613 type_aware_require: args.type_aware_require.map(Into::into),
5614 trace_opts: &args.trace_opts,
5615 explain: cli.explain,
5616 top: args.top,
5617 file: &args.file,
5618 scope: args.scope.clone(),
5619 include_entry_exports: cli.include_entry_exports,
5620 summary: cli.summary,
5621 regression_opts: dispatch.regression_opts(
5622 cli.changed_since.is_some()
5623 || cli.workspace.is_some()
5624 || cli.changed_workspaces.is_some()
5625 || !args.file.is_empty()
5626 || args.scope.is_some(),
5627 ),
5628 retain_modules_for_health: false,
5629 defer_performance: false,
5630 analysis_snapshot: fallow_config::AnalysisSnapshot::Current,
5631 explain_skipped: cli.explain_skipped,
5632 })
5633}
5634
5635fn validate_type_aware_check_options(
5636 dispatch: &DispatchContext<'_>,
5637 args: &CheckDispatchArgs,
5638) -> Option<ExitCode> {
5639 let output = dispatch.output;
5640 if !args.type_aware_project.is_empty() && args.type_aware != Some(true) {
5641 return Some(emit_error(
5642 "--type-aware-project requires --type-aware",
5643 2,
5644 output,
5645 ));
5646 }
5647 if args.type_aware_require.is_some() && args.type_aware != Some(true) {
5648 return Some(emit_error(
5649 "--type-aware-require requires --type-aware",
5650 2,
5651 output,
5652 ));
5653 }
5654 if args.trace_opts.symbol_impact.is_some() && args.type_aware != Some(true) {
5655 return Some(emit_error(
5656 "--symbol-impact requires --type-aware",
5657 2,
5658 output,
5659 ));
5660 }
5661 let focused_output = args.trace_opts.trace_export.is_some()
5662 || args.trace_opts.trace_file.is_some()
5663 || args.trace_opts.trace_dependency.is_some()
5664 || args.trace_opts.impact_closure.is_some()
5665 || args.trace_opts.symbol_impact.is_some();
5666 if focused_output
5667 && !matches!(
5668 output,
5669 fallow_config::OutputFormat::Human | fallow_config::OutputFormat::Json
5670 )
5671 {
5672 return Some(emit_error(
5673 "focused trace and impact queries support human and JSON output",
5674 2,
5675 output,
5676 ));
5677 }
5678 if args.type_aware == Some(true)
5679 && !matches!(
5680 output,
5681 fallow_config::OutputFormat::Human
5682 | fallow_config::OutputFormat::Json
5683 | fallow_config::OutputFormat::Sarif
5684 | fallow_config::OutputFormat::Compact
5685 | fallow_config::OutputFormat::Markdown
5686 | fallow_config::OutputFormat::CodeClimate
5687 | fallow_config::OutputFormat::PrCommentGithub
5688 | fallow_config::OutputFormat::PrCommentGitlab
5689 | fallow_config::OutputFormat::ReviewGithub
5690 | fallow_config::OutputFormat::ReviewGitlab
5691 )
5692 {
5693 return Some(emit_error(
5694 "--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",
5695 2,
5696 output,
5697 ));
5698 }
5699 None
5700}
5701
5702fn resolve_ignore_imports(ignore_imports: bool, no_ignore_imports: bool) -> Option<bool> {
5708 if no_ignore_imports {
5709 Some(false)
5710 } else if ignore_imports {
5711 Some(true)
5712 } else {
5713 None
5714 }
5715}
5716
5717struct DupesDispatchArgs {
5718 mode: Option<DupesMode>,
5719 near: bool,
5720 min_tokens: Option<usize>,
5721 min_lines: Option<usize>,
5722 min_occurrences: Option<usize>,
5723 threshold: Option<f64>,
5724 skip_local: bool,
5725 cross_language: bool,
5726 ignore_imports: bool,
5727 no_ignore_imports: bool,
5728 top: Option<usize>,
5729 no_fragments: bool,
5730 trace: Option<String>,
5731 scope: Option<std::path::PathBuf>,
5732}
5733
5734fn dispatch_dupes(dispatch: &DispatchContext<'_>, args: &DupesDispatchArgs) -> ExitCode {
5735 let cli = dispatch.cli;
5736 let (output, quiet, _fail_on_issues) =
5737 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
5738 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::Dupes) {
5739 Ok(production) => production,
5740 Err(code) => return code,
5741 };
5742 dupes::run_dupes(&DupesOptions {
5743 root: dispatch.root,
5744 config_path: &cli.config,
5745 output,
5746 json_style: dispatch.json_style,
5747 no_cache: cli.no_cache,
5748 threads: dispatch.threads,
5749 quiet,
5750 allow_remote_extends: cli.allow_remote_extends,
5751 mode: args.mode,
5752 near: args.near,
5753 min_tokens: args.min_tokens,
5754 min_lines: args.min_lines,
5755 min_occurrences: args.min_occurrences,
5756 threshold: args.threshold,
5757 skip_local: args.skip_local,
5758 cross_language: args.cross_language,
5759 ignore_imports: resolve_ignore_imports(args.ignore_imports, args.no_ignore_imports),
5760 top: args.top,
5761 baseline_path: cli.baseline.as_deref(),
5762 save_baseline_path: cli.save_baseline.as_deref(),
5763 fail_on_stale_baseline: cli.fail_on_stale_baseline,
5764 production,
5765 production_override: Some(production),
5766 trace: args.trace.as_deref(),
5767 changed_since: cli.changed_since.as_deref(),
5768 diff_index: None,
5769 use_shared_diff_index: true,
5770 changed_files: None,
5771 workspace: cli.workspace.as_deref(),
5772 changed_workspaces: cli.changed_workspaces.as_deref(),
5773 explain: cli.explain,
5774 explain_skipped: cli.explain_skipped,
5775 summary: cli.summary,
5776 group_by: cli.group_by,
5777 performance: cli.performance,
5778 include_fragments: !args.no_fragments,
5779 scope: args.scope.clone(),
5780 })
5781}
5782
5783struct AuditDispatchArgs {
5784 production_dead_code: bool,
5785 production_health: bool,
5786 production_dupes: bool,
5787 dead_code_baseline: Option<PathBuf>,
5788 health_baseline: Option<PathBuf>,
5789 dupes_baseline: Option<PathBuf>,
5790 max_crap: Option<f64>,
5791 coverage: Option<PathBuf>,
5792 coverage_root: Option<PathBuf>,
5793 no_css: bool,
5794 css_deep: bool,
5795 no_css_deep: bool,
5796 gate: Option<AuditGateArg>,
5797 runtime_coverage: Option<PathBuf>,
5798 min_invocations_hot: u64,
5799 gate_marker: Option<String>,
5800 brief: bool,
5801 max_decisions: usize,
5802 walkthrough_guide: bool,
5804 walkthrough_file: Option<PathBuf>,
5807 walkthrough: bool,
5809 mark_viewed: Vec<PathBuf>,
5811 show_cleared: bool,
5813 show_deprioritized: bool,
5815 scope: Option<PathBuf>,
5816}
5817
5818struct ResolvedAuditInputs {
5819 audit_cfg: fallow_config::AuditConfig,
5820 cache_dir: PathBuf,
5821 production: ProductionModes,
5822 dead_code_baseline: Option<PathBuf>,
5823 health_baseline: Option<PathBuf>,
5824 dupes_baseline: Option<PathBuf>,
5825 coverage: Option<PathBuf>,
5829 coverage_root: Option<PathBuf>,
5830}
5831
5832fn dispatch_audit(dispatch: &DispatchContext<'_>, args: &AuditDispatchArgs) -> ExitCode {
5833 let cli = dispatch.cli;
5834 let output = dispatch.output;
5835
5836 if cli.baseline.is_some() || cli.save_baseline.is_some() {
5837 return emit_error(
5838 "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>`)",
5839 2,
5840 output,
5841 );
5842 }
5843
5844 let inputs = match resolve_audit_inputs(dispatch, args) {
5845 Ok(inputs) => inputs,
5846 Err(code) => return code,
5847 };
5848
5849 run_resolved_audit(dispatch, args, &inputs)
5850}
5851
5852fn resolve_audit_inputs(
5853 dispatch: &DispatchContext<'_>,
5854 args: &AuditDispatchArgs,
5855) -> Result<ResolvedAuditInputs, ExitCode> {
5856 let cli = dispatch.cli;
5857 let root = dispatch.root;
5858 let output = dispatch.output;
5859 let config = load_config(
5860 root,
5861 &cli.config,
5862 LoadConfigArgs {
5863 output,
5864 no_cache: cli.no_cache,
5865 threads: dispatch.threads,
5866 production: cli.production,
5867 quiet: dispatch.quiet,
5868 allow_remote_extends: cli.allow_remote_extends,
5869 },
5870 )?;
5871 let cache_dir = config.cache_dir.clone();
5872 let audit_cfg = config.audit;
5873 let production = resolve_production_modes(
5874 cli,
5875 root,
5876 output,
5877 args.production_dead_code,
5878 args.production_health,
5879 args.production_dupes,
5880 )?;
5881 let resolved_dead_code_baseline = resolve_audit_baseline_path(
5882 root,
5883 args.dead_code_baseline.as_deref(),
5884 audit_cfg.dead_code_baseline.as_deref(),
5885 );
5886 let resolved_health_baseline = resolve_audit_baseline_path(
5887 root,
5888 args.health_baseline.as_deref(),
5889 audit_cfg.health_baseline.as_deref(),
5890 );
5891 let resolved_dupes_baseline = resolve_audit_baseline_path(
5892 root,
5893 args.dupes_baseline.as_deref(),
5894 audit_cfg.dupes_baseline.as_deref(),
5895 );
5896 let coverage_inputs = resolve_coverage_inputs(
5897 args.coverage.as_deref(),
5898 args.coverage_root.as_deref(),
5899 output,
5900 || Ok(config.health),
5901 )?;
5902
5903 Ok(ResolvedAuditInputs {
5904 audit_cfg,
5905 cache_dir,
5906 production,
5907 dead_code_baseline: resolved_dead_code_baseline,
5908 health_baseline: resolved_health_baseline,
5909 dupes_baseline: resolved_dupes_baseline,
5910 coverage: coverage_inputs.coverage,
5911 coverage_root: coverage_inputs.coverage_root,
5912 })
5913}
5914
5915fn audit_css_enabled(config: &fallow_config::AuditConfig, args: &AuditDispatchArgs) -> bool {
5916 !args.no_css && config.css.unwrap_or(true)
5917}
5918
5919fn audit_css_deep_enabled(config: &fallow_config::AuditConfig, args: &AuditDispatchArgs) -> bool {
5920 audit_css_enabled(config, args)
5921 && !args.no_css_deep
5922 && (args.css_deep || config.css_deep.unwrap_or(true))
5923}
5924
5925fn run_resolved_audit(
5926 dispatch: &DispatchContext<'_>,
5927 args: &AuditDispatchArgs,
5928 inputs: &ResolvedAuditInputs,
5929) -> ExitCode {
5930 let cli = dispatch.cli;
5931 audit::run_audit_with_type_aware(
5932 &audit::AuditOptions {
5933 root: dispatch.root,
5934 config_path: &cli.config,
5935 cache_dir: &inputs.cache_dir,
5936 output: dispatch.output,
5937 json_style: dispatch.json_style,
5938 no_cache: cli.no_cache,
5939 threads: dispatch.threads,
5940 quiet: dispatch.quiet,
5941 allow_remote_extends: cli.allow_remote_extends,
5942 changed_since: cli.changed_since.as_deref(),
5943 production: cli.production,
5944 production_dead_code: Some(inputs.production.dead_code),
5945 production_health: Some(inputs.production.health),
5946 production_dupes: Some(inputs.production.dupes),
5947 workspace: cli.workspace.as_deref(),
5948 changed_workspaces: cli.changed_workspaces.as_deref(),
5949 explain: cli.explain,
5950 explain_skipped: cli.explain_skipped,
5951 performance: cli.performance,
5952 group_by: cli.group_by,
5953 dead_code_baseline: inputs.dead_code_baseline.as_deref(),
5954 health_baseline: inputs.health_baseline.as_deref(),
5955 dupes_baseline: inputs.dupes_baseline.as_deref(),
5956 health_baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
5957 fail_on_stale_baseline: cli.fail_on_stale_baseline,
5958 max_crap: args.max_crap,
5959 coverage: inputs.coverage.as_deref(),
5960 coverage_root: inputs.coverage_root.as_deref(),
5961 gate: args.gate.map_or(inputs.audit_cfg.gate, Into::into),
5962 include_entry_exports: cli.include_entry_exports,
5963 css: audit_css_enabled(&inputs.audit_cfg, args),
5967 css_deep: audit_css_deep_enabled(&inputs.audit_cfg, args),
5968 runtime_coverage: args.runtime_coverage.as_deref(),
5969 min_invocations_hot: args.min_invocations_hot,
5970 brief: args.brief,
5971 max_decisions: args.max_decisions,
5972 walkthrough_guide: args.walkthrough_guide,
5973 walkthrough: args.walkthrough,
5974 mark_viewed: &args.mark_viewed,
5975 show_cleared: args.show_cleared,
5976 walkthrough_file: args.walkthrough_file.as_deref(),
5977 show_deprioritized: args.show_deprioritized,
5978 scope: args.scope.clone(),
5979 },
5980 args.gate_marker.as_deref(),
5981 audit::AuditTypeAwareOptions {
5982 enabled: cli.type_aware_override(),
5983 config_default: inputs.audit_cfg.type_aware,
5984 projects: &cli.type_aware_project,
5985 require: cli.type_aware_require.map(Into::into),
5986 },
5987 )
5988}
5989
5990fn dispatch_decision_surface(dispatch: &DispatchContext<'_>, max_decisions: usize) -> ExitCode {
5994 let args = decision_surface_audit_args(max_decisions);
5995 let inputs = match resolve_audit_inputs(dispatch, &args) {
5996 Ok(inputs) => inputs,
5997 Err(code) => return code,
5998 };
5999 if dispatch.cli.fail_on_stale_baseline {
6003 for path in [
6004 &inputs.dead_code_baseline,
6005 &inputs.health_baseline,
6006 &inputs.dupes_baseline,
6007 ] {
6008 baseline_gate::note_stood_down(
6009 path.as_deref(),
6010 true,
6011 "decision-surface renders a brief without exit gates",
6012 );
6013 }
6014 }
6015 audit::run_decision_surface(&decision_surface_audit_options(
6016 dispatch,
6017 &inputs,
6018 max_decisions,
6019 ))
6020}
6021
6022fn decision_surface_audit_args(max_decisions: usize) -> AuditDispatchArgs {
6023 AuditDispatchArgs {
6024 production_dead_code: false,
6025 production_health: false,
6026 production_dupes: false,
6027 dead_code_baseline: None,
6028 health_baseline: None,
6029 dupes_baseline: None,
6030 max_crap: None,
6031 coverage: None,
6032 coverage_root: None,
6033 no_css: true,
6034 css_deep: false,
6035 no_css_deep: false,
6036 gate: None,
6037 runtime_coverage: None,
6038 min_invocations_hot: 0,
6039 gate_marker: None,
6040 brief: true,
6041 max_decisions,
6042 walkthrough_guide: false,
6043 walkthrough_file: None,
6044 walkthrough: false,
6045 mark_viewed: Vec::new(),
6046 show_cleared: false,
6047 show_deprioritized: false,
6048 scope: None,
6049 }
6050}
6051
6052fn decision_surface_audit_options<'a>(
6053 dispatch: &'a DispatchContext<'a>,
6054 inputs: &'a ResolvedAuditInputs,
6055 max_decisions: usize,
6056) -> audit::AuditOptions<'a> {
6057 let cli = dispatch.cli;
6058 audit::AuditOptions {
6059 root: dispatch.root,
6060 config_path: &cli.config,
6061 cache_dir: &inputs.cache_dir,
6062 output: dispatch.output,
6063 json_style: dispatch.json_style,
6064 no_cache: cli.no_cache,
6065 threads: dispatch.threads,
6066 quiet: dispatch.quiet,
6067 allow_remote_extends: cli.allow_remote_extends,
6068 changed_since: cli.changed_since.as_deref(),
6069 production: cli.production,
6070 production_dead_code: Some(inputs.production.dead_code),
6071 production_health: Some(inputs.production.health),
6072 production_dupes: Some(inputs.production.dupes),
6073 workspace: cli.workspace.as_deref(),
6074 changed_workspaces: cli.changed_workspaces.as_deref(),
6075 explain: cli.explain,
6076 explain_skipped: cli.explain_skipped,
6077 performance: cli.performance,
6078 group_by: cli.group_by,
6079 dead_code_baseline: inputs.dead_code_baseline.as_deref(),
6080 health_baseline: inputs.health_baseline.as_deref(),
6081 dupes_baseline: inputs.dupes_baseline.as_deref(),
6082 health_baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
6083 fail_on_stale_baseline: false,
6087 max_crap: None,
6088 coverage: None,
6089 coverage_root: None,
6090 gate: inputs.audit_cfg.gate,
6091 include_entry_exports: cli.include_entry_exports,
6092 css: false,
6094 css_deep: false,
6095 runtime_coverage: None,
6096 min_invocations_hot: 0,
6097 brief: true,
6098 max_decisions,
6099 walkthrough_guide: false,
6100 walkthrough: false,
6101 mark_viewed: &[],
6102 show_cleared: false,
6103 walkthrough_file: None,
6104 show_deprioritized: false,
6105 scope: None,
6106 }
6107}
6108
6109struct HealthDispatchArgs<'a> {
6110 max_cyclomatic: Option<u16>,
6111 max_cognitive: Option<u16>,
6112 max_crap: Option<f64>,
6113 top: Option<usize>,
6114 sort: health::SortBy,
6115 complexity: bool,
6116 complexity_breakdown: bool,
6117 file_scores: bool,
6118 coverage_gaps: bool,
6119 hotspots: bool,
6120 ownership: bool,
6121 ownership_emails: Option<fallow_config::EmailMode>,
6122 targets: bool,
6123 type_coupling: bool,
6124 css: bool,
6125 effort: Option<EffortFilter>,
6126 score: bool,
6127 min_score: Option<f64>,
6128 min_severity: Option<fallow_output::FindingSeverity>,
6129 report_only: bool,
6130 fail_on_stale_baseline: bool,
6131 since: Option<&'a str>,
6132 min_commits: Option<u32>,
6133 save_snapshot: Option<&'a Option<String>>,
6134 trend: bool,
6135 coverage: Option<&'a std::path::Path>,
6136 coverage_root: Option<&'a std::path::Path>,
6137 runtime_coverage: Option<&'a std::path::Path>,
6138 min_invocations_hot: u64,
6139 min_observation_volume: Option<u32>,
6140 low_traffic_threshold: Option<f64>,
6141 scope: Option<std::path::PathBuf>,
6142}
6143
6144type ResolvedHealthCoverageInputs = fallow_api::CoverageInputs;
6145
6146fn resolve_coverage_inputs(
6157 cli_coverage: Option<&std::path::Path>,
6158 cli_coverage_root: Option<&std::path::Path>,
6159 output: fallow_config::OutputFormat,
6160 config_health: impl FnOnce() -> Result<fallow_config::HealthConfig, ExitCode>,
6161) -> Result<ResolvedHealthCoverageInputs, ExitCode> {
6162 let explicit = fallow_api::CoverageInputs {
6163 coverage: cli_coverage.map(std::path::Path::to_path_buf),
6164 coverage_root: cli_coverage_root.map(std::path::Path::to_path_buf),
6165 };
6166 let env = fallow_api::CoverageInputs {
6167 coverage: path_from_env("FALLOW_COVERAGE"),
6168 coverage_root: path_from_env("FALLOW_COVERAGE_ROOT"),
6169 };
6170 let config_health = if fallow_api::CoverageInputs::needs_config_layer(&explicit, &env) {
6171 Some(config_health()?)
6172 } else {
6173 None
6174 };
6175
6176 fallow_api::resolve_coverage_inputs(explicit, env, config_health.as_ref())
6177 .map_err(|err| emit_error(&err.to_string(), 2, output))
6178}
6179
6180fn resolve_health_coverage_inputs(
6183 dispatch: &DispatchContext<'_>,
6184 cli_coverage: Option<&std::path::Path>,
6185 cli_coverage_root: Option<&std::path::Path>,
6186) -> Result<ResolvedHealthCoverageInputs, ExitCode> {
6187 resolve_coverage_inputs(cli_coverage, cli_coverage_root, dispatch.output, || {
6188 Ok(load_config(
6189 dispatch.root,
6190 &dispatch.cli.config,
6191 LoadConfigArgs {
6192 output: dispatch.output,
6193 no_cache: dispatch.cli.no_cache,
6194 threads: dispatch.threads,
6195 production: dispatch.cli.production,
6196 quiet: dispatch.quiet,
6197 allow_remote_extends: dispatch.cli.allow_remote_extends,
6198 },
6199 )?
6200 .health)
6201 })
6202}
6203
6204fn path_from_env(name: &str) -> Option<PathBuf> {
6205 std::env::var_os(name)
6206 .filter(|value| !value.is_empty())
6207 .map(PathBuf::from)
6208}
6209
6210fn validate_health_report_only_gate(
6211 report_only: bool,
6212 min_score: Option<f64>,
6213 min_severity: Option<fallow_output::FindingSeverity>,
6214 output: fallow_config::OutputFormat,
6215) -> Result<(), ExitCode> {
6216 if report_only && (min_score.is_some() || min_severity.is_some()) {
6217 return Err(emit_error(
6218 "--report-only cannot be combined with --min-score or --min-severity. \
6219 --report-only always exits 0; drop it to gate on score/severity, or \
6220 drop the gate flags to stay advisory.",
6221 2,
6222 output,
6223 ));
6224 }
6225
6226 Ok(())
6227}
6228
6229fn resolve_runtime_coverage_options(
6230 runtime_coverage: Option<&std::path::Path>,
6231 min_invocations_hot: u64,
6232 min_observation_volume: Option<u32>,
6233 low_traffic_threshold: Option<f64>,
6234 output: fallow_config::OutputFormat,
6235) -> Result<Option<fallow_engine::health::RuntimeCoverageOptions>, ExitCode> {
6236 let Some(path) = runtime_coverage else {
6237 return Ok(None);
6238 };
6239
6240 health::coverage::prepare_options(
6241 path,
6242 min_invocations_hot,
6243 min_observation_volume,
6244 low_traffic_threshold,
6245 output,
6246 )
6247 .map(Some)
6248}
6249
6250fn dispatch_health(dispatch: &DispatchContext<'_>, args: &HealthDispatchArgs<'_>) -> ExitCode {
6251 let cli = dispatch.cli;
6252 let root = dispatch.root;
6253 let (output, _quiet, _fail_on_issues) =
6254 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
6255 if let Err(code) = validate_health_report_only_gate(
6256 args.report_only,
6257 args.min_score,
6258 args.min_severity,
6259 output,
6260 ) {
6261 return code;
6262 }
6263 let runtime_coverage = match resolve_runtime_coverage_options(
6264 args.runtime_coverage,
6265 args.min_invocations_hot,
6266 args.min_observation_volume,
6267 args.low_traffic_threshold,
6268 output,
6269 ) {
6270 Ok(options) => options,
6271 Err(code) => return code,
6272 };
6273 let production = match resolve_production_modes(cli, root, output, false, false, false) {
6274 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::Health),
6275 Err(code) => return code,
6276 };
6277 let coverage_inputs =
6278 match resolve_health_coverage_inputs(dispatch, args.coverage, args.coverage_root) {
6279 Ok(inputs) => inputs,
6280 Err(code) => return code,
6281 };
6282 let run = derive_health_dispatch_run(args, output, &coverage_inputs, runtime_coverage);
6283 run_health_dispatch(dispatch, args, ResolvedHealthDispatch { run, production })
6284}
6285
6286fn derive_health_dispatch_run<'a>(
6287 args: &'a HealthDispatchArgs<'a>,
6288 output: fallow_config::OutputFormat,
6289 coverage_inputs: &'a ResolvedHealthCoverageInputs,
6290 runtime_coverage: Option<fallow_engine::health::RuntimeCoverageOptions>,
6291) -> fallow_engine::health::HealthRunOptions<'a> {
6292 let mut run = fallow_engine::health::derive_health_run_options(
6293 fallow_engine::health::HealthRunOptionsInput {
6294 output,
6295 thresholds: health_threshold_overrides(args),
6296 top: args.top,
6297 sort: args.sort.clone().into(),
6298 complexity: args.complexity,
6299 file_scores: args.file_scores,
6300 coverage_gaps: args.coverage_gaps,
6301 hotspots: args.hotspots,
6302 ownership: args.ownership,
6303 ownership_emails: args.ownership_emails,
6304 targets: args.targets,
6305 css: args.css,
6306 effort: args.effort.map(EffortFilter::to_estimate),
6307 score: args.score,
6308 gates: health_gate_options(args),
6309 snapshot_requested: args.save_snapshot.is_some(),
6310 trend: args.trend,
6311 since: args.since,
6312 min_commits: args.min_commits,
6313 coverage_inputs: health_coverage_inputs(coverage_inputs),
6314 runtime_coverage,
6315 },
6316 );
6317 if args.type_coupling && !run.sections.any_section {
6318 run.sections = fallow_engine::health::DerivedHealthSections {
6319 any_section: true,
6320 complexity: false,
6321 file_scores: false,
6322 coverage_gaps: false,
6323 hotspots: false,
6324 targets: false,
6325 css: false,
6326 score: false,
6327 force_full: false,
6328 score_only_output: false,
6329 };
6330 }
6331 run
6332}
6333
6334fn health_threshold_overrides(
6335 args: &HealthDispatchArgs<'_>,
6336) -> fallow_engine::health::HealthThresholdOverrides {
6337 fallow_engine::health::HealthThresholdOverrides {
6338 max_cyclomatic: args.max_cyclomatic,
6339 max_cognitive: args.max_cognitive,
6340 max_crap: args.max_crap,
6341 }
6342}
6343
6344fn health_gate_options(args: &HealthDispatchArgs<'_>) -> fallow_engine::health::HealthGateOptions {
6345 fallow_engine::health::HealthGateOptions {
6346 min_score: args.min_score,
6347 min_severity: args.min_severity,
6348 report_only: args.report_only,
6349 fail_on_stale_baseline: args.fail_on_stale_baseline,
6350 }
6351}
6352
6353fn health_coverage_inputs(
6354 coverage_inputs: &ResolvedHealthCoverageInputs,
6355) -> fallow_engine::health::HealthCoverageInputs<'_> {
6356 fallow_engine::health::HealthCoverageInputs {
6357 coverage: coverage_inputs.coverage.as_deref(),
6358 coverage_root: coverage_inputs.coverage_root.as_deref(),
6359 coverage_relocated: false,
6360 }
6361}
6362
6363struct ResolvedHealthDispatch<'a> {
6367 run: fallow_engine::health::HealthRunOptions<'a>,
6368 production: bool,
6369}
6370
6371fn run_health_dispatch(
6374 dispatch: &DispatchContext<'_>,
6375 args: &HealthDispatchArgs<'_>,
6376 resolved: ResolvedHealthDispatch<'_>,
6377) -> ExitCode {
6378 let cli = dispatch.cli;
6379 let (output, quiet, _fail_on_issues) =
6380 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
6381 let run = resolved.run;
6382 let sections = run.sections;
6383 let production = resolved.production;
6384 health::run_health(
6385 &HealthOptions {
6386 root: dispatch.root,
6387 config_path: &cli.config,
6388 output,
6389 no_cache: cli.no_cache,
6390 threads: dispatch.threads,
6391 quiet,
6392 thresholds: run.thresholds,
6393 top: run.top,
6394 sort: run.sort,
6395 production,
6396 production_override: Some(production),
6397 allow_remote_extends: cli.allow_remote_extends,
6398 changed_since: cli.changed_since.as_deref(),
6399 diff_index: None,
6400 use_shared_diff_index: true,
6401 workspace: cli.workspace.as_deref(),
6402 changed_workspaces: cli.changed_workspaces.as_deref(),
6403 baseline: cli.baseline.as_deref(),
6404 save_baseline: cli.save_baseline.as_deref(),
6405 baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
6406 baseline_mode_explicit: cli.baseline_mode.is_some(),
6407 complexity: sections.complexity,
6408 file_scores: sections.file_scores,
6409 coverage_gaps: sections.coverage_gaps,
6410 config_activates_coverage_gaps: !sections.any_section,
6411 hotspots: sections.hotspots,
6412 ownership: run.ownership,
6413 ownership_emails: run.ownership_emails,
6414 targets: sections.targets,
6415 css: sections.css,
6416 css_deep: false,
6417 force_full: sections.force_full,
6418 score_only_output: sections.score_only_output,
6419 enforce_coverage_gap_gate: true,
6420 effort: run.effort,
6421 score: sections.score,
6422 gates: run.gates,
6423 since: run.since,
6424 min_commits: run.min_commits,
6425 explain: cli.explain,
6426 summary: cli.summary,
6427 save_snapshot: args
6428 .save_snapshot
6429 .map(|opt| PathBuf::from(opt.as_deref().unwrap_or_default())),
6430 trend: args.trend,
6431 coverage_inputs: run.coverage_inputs,
6432 performance: cli.performance,
6433 runtime_coverage: run.runtime_coverage,
6434 churn_file: cli.churn_file.as_deref(),
6435 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
6436 complexity_breakdown: args.complexity_breakdown,
6437 group_by: cli.group_by.map(Into::into),
6438 scope: args.scope.clone(),
6439 },
6440 dispatch.json_style,
6441 &health::TypeAwareHealthOptions {
6442 enabled: cli.type_aware_override(),
6443 requested: args.type_coupling,
6444 unfiltered: health_type_coupling_is_default_section(args),
6445 projects: &cli.type_aware_project,
6446 require: cli.type_aware_require.map(Into::into),
6447 },
6448 )
6449}
6450
6451fn health_type_coupling_is_default_section(args: &HealthDispatchArgs<'_>) -> bool {
6452 !args.complexity
6453 && !args.file_scores
6454 && !args.coverage_gaps
6455 && !args.hotspots
6456 && !args.ownership
6457 && !args.targets
6458 && !args.css
6459 && !args.score
6460 && args.min_score.is_none()
6461 && args.min_severity.is_none()
6462 && args.runtime_coverage.is_none()
6463}
6464
6465#[cfg(test)]
6466mod tests {
6467 use super::*;
6468
6469 #[test]
6473 fn cli_definition_has_no_flag_collisions() {
6474 use clap::CommandFactory;
6475 Cli::command().debug_assert();
6476 }
6477
6478 #[test]
6479 fn impact_statusline_subcommand_parses() {
6480 use clap::Parser;
6481
6482 let cli = Cli::try_parse_from(["fallow", "impact", "statusline"]).expect("argv parses");
6483 assert!(matches!(
6484 cli.command,
6485 Some(Command::Impact {
6486 subcommand: Some(ImpactCli::Statusline),
6487 ..
6488 })
6489 ));
6490 }
6491
6492 #[test]
6493 fn impact_statusline_bypasses_command_epilogue() {
6494 use clap::Parser;
6495
6496 let statusline =
6497 Cli::try_parse_from(["fallow", "impact", "statusline"]).expect("argv parses");
6498 assert!(is_impact_statusline(&statusline));
6499
6500 let status = Cli::try_parse_from(["fallow", "impact", "status"]).expect("argv parses");
6501 assert!(!is_impact_statusline(&status));
6502
6503 let all_statusline =
6504 Cli::try_parse_from(["fallow", "impact", "--all", "statusline"]).expect("argv parses");
6505 assert!(!is_impact_statusline(&all_statusline));
6506 }
6507
6508 #[test]
6509 fn regression_baseline_help_explains_the_default_destination() {
6510 use clap::CommandFactory;
6511 let help = Cli::command().render_long_help().to_string();
6512
6513 assert!(help.contains("Omit PATH to update regression.baseline"));
6514 assert!(help.contains("discovered fallow config"));
6515 assert!(help.contains("create .fallowrc.json when none exists"));
6516 }
6517
6518 #[test]
6522 fn after_help_lists_every_task_matrix_command() {
6523 for row in crate::task_matrix::TASK_MATRIX {
6524 assert!(
6525 TOP_LEVEL_AFTER_LONG_HELP.contains(row.command),
6526 "root --help cheat sheet is missing task-matrix command '{}'; \
6527 update the top_level_task_cheat_sheet! fragment to match TASK_MATRIX",
6528 row.command
6529 );
6530 }
6531 }
6532
6533 #[test]
6540 fn after_help_lists_every_visible_subcommand() {
6541 use clap::CommandFactory;
6542
6543 for sub in Cli::command().get_subcommands() {
6544 if sub.is_hide_set() {
6545 continue;
6546 }
6547 let name = sub.get_name();
6548 let listed = TOP_LEVEL_AFTER_LONG_HELP
6549 .lines()
6550 .any(|line| line.split_whitespace().next() == Some(name));
6551 assert!(
6552 listed,
6553 "root --help command list is missing subcommand '{name}'; \
6554 add it to a top_level_*_command_groups! section"
6555 );
6556 }
6557 }
6558
6559 #[test]
6563 fn short_help_stays_scannable_with_cheat_sheet_and_pointer() {
6564 use clap::CommandFactory;
6565
6566 let help = Cli::command().render_help().to_string();
6567 let lines = help.lines().count();
6568 assert!(
6569 lines < 90,
6570 "root -h grew to {lines} lines; keep the short surface under 90 \
6571 (curate hide_short_help and the short after-help instead)"
6572 );
6573 assert!(help.contains("When the agent is about to..."));
6574 assert!(help.contains("Run fallow --help for the complete command list."));
6575 }
6576
6577 #[test]
6581 fn high_value_commands_route_to_distinct_workflows() {
6582 use clap::Parser;
6583 use fallow_config::OutputFormat;
6584
6585 let distinct = [
6586 (vec!["fallow", "impact"], telemetry::Workflow::Impact),
6587 (vec!["fallow", "security"], telemetry::Workflow::Security),
6588 (vec!["fallow", "fix"], telemetry::Workflow::Fix),
6589 (
6590 vec!["fallow", "explain", "unused-exports"],
6591 telemetry::Workflow::Explain,
6592 ),
6593 (
6594 vec!["fallow", "watch"],
6595 telemetry::Workflow::CodeQualityReview,
6596 ),
6597 (
6598 vec!["fallow", "list"],
6599 telemetry::Workflow::ProjectInventory,
6600 ),
6601 (
6602 vec!["fallow", "workspaces"],
6603 telemetry::Workflow::ProjectInventory,
6604 ),
6605 (
6606 vec!["fallow", "schema"],
6607 telemetry::Workflow::ProjectInventory,
6608 ),
6609 (vec!["fallow", "init"], telemetry::Workflow::Setup),
6610 (
6611 vec!["fallow", "hooks", "install", "--target", "git"],
6612 telemetry::Workflow::Setup,
6613 ),
6614 (vec!["fallow", "config-schema"], telemetry::Workflow::Setup),
6615 (vec!["fallow", "plugin-schema"], telemetry::Workflow::Setup),
6616 (
6617 vec!["fallow", "rule-pack-schema"],
6618 telemetry::Workflow::Setup,
6619 ),
6620 (vec!["fallow", "config"], telemetry::Workflow::Setup),
6621 (
6622 vec!["fallow", "ci-template", "gitlab"],
6623 telemetry::Workflow::Setup,
6624 ),
6625 (vec!["fallow", "migrate"], telemetry::Workflow::Setup),
6626 (
6627 vec!["fallow", "telemetry", "status"],
6628 telemetry::Workflow::Setup,
6629 ),
6630 (vec!["fallow", "setup-hooks"], telemetry::Workflow::Setup),
6631 (
6632 vec!["fallow", "audit-cache", "remove", "--root", "."],
6633 telemetry::Workflow::Setup,
6634 ),
6635 (
6636 vec!["fallow", "license", "status"],
6637 telemetry::Workflow::License,
6638 ),
6639 ];
6640 for (argv, expected) in distinct {
6641 let cli = Cli::try_parse_from(&argv).expect("argv parses");
6642 assert_eq!(
6643 telemetry_workflow_for_command(cli.command.as_ref(), OutputFormat::Json),
6644 expected,
6645 "{argv:?} should map to {expected:?}"
6646 );
6647 }
6648 }
6649
6650 #[test]
6655 fn version_flag_accepts_lower_v_upper_v_and_long() {
6656 use clap::CommandFactory;
6657 for argv in [["fallow", "-v"], ["fallow", "-V"], ["fallow", "--version"]] {
6658 let err = Cli::command()
6659 .try_get_matches_from(argv)
6660 .expect_err("version flag should short-circuit parsing");
6661 assert_eq!(
6662 err.kind(),
6663 clap::error::ErrorKind::DisplayVersion,
6664 "{argv:?} should trigger the Version action"
6665 );
6666 }
6667 }
6668
6669 #[test]
6674 fn cli_help_text_contains_no_implementation_status_wording() {
6675 use clap::CommandFactory;
6676 let mut root = Cli::command();
6677 let mut violations: Vec<(String, String)> = Vec::new();
6678 visit_help(&mut root, "fallow", &mut violations);
6679 assert!(
6680 violations.is_empty(),
6681 "found implementation-status wording in --help output:\n{}",
6682 violations
6683 .iter()
6684 .map(|(cmd, line)| format!(" {cmd}: {line}"))
6685 .collect::<Vec<_>>()
6686 .join("\n")
6687 );
6688 }
6689
6690 #[test]
6691 fn dependency_override_help_is_package_manager_neutral() {
6692 use clap::CommandFactory;
6693 let help = Cli::command()
6694 .find_subcommand_mut("dead-code")
6695 .expect("dead-code command")
6696 .render_long_help()
6697 .to_string();
6698
6699 assert!(help.contains("Only report unused package-manager dependency overrides"));
6700 assert!(help.contains("Only report misconfigured package-manager dependency overrides"));
6701 assert!(!help.contains("unused pnpm dependency overrides"));
6702 assert!(!help.contains("misconfigured pnpm dependency overrides"));
6703 }
6704
6705 #[test]
6706 fn top_level_help_groups_commands_by_workflow() {
6707 use clap::CommandFactory;
6708 let help = Cli::command().render_long_help().to_string();
6709 let expected_order = [
6710 "Analysis:",
6711 " dead-code",
6712 " dupes",
6713 " health",
6714 " flags",
6715 " security",
6716 " audit",
6717 "Workflow:",
6718 " watch",
6719 " fix",
6720 "Project inspection:",
6721 " list",
6722 " workspaces",
6723 " explain",
6724 " impact",
6725 " viz",
6726 "Setup and configuration:",
6727 " init",
6728 " recommend",
6729 " migrate",
6730 " config",
6731 " config-schema",
6732 " plugin-schema",
6733 " plugin-check",
6734 " rule-pack-schema",
6735 "Automation and CI:",
6736 " ci",
6737 " ci-template",
6738 " hooks",
6739 " setup-hooks",
6740 "Runtime coverage:",
6741 " coverage",
6742 " license",
6743 "Reference:",
6744 " schema",
6745 " help",
6746 "Options:",
6747 ];
6748 let mut cursor = 0;
6749 for needle in expected_order {
6750 let Some(offset) = help[cursor..].find(needle) else {
6751 panic!("top-level help missing `{needle}` after byte {cursor}:\n{help}");
6752 };
6753 cursor += offset + needle.len();
6754 }
6755 }
6756
6757 #[test]
6758 fn security_help_hides_globals_rejected_by_security_validator() {
6759 let help = render_security_help(SecurityHelpTarget::Parent);
6760
6761 for long in SECURITY_UNSUPPORTED_GLOBAL_LONGS {
6762 assert!(
6763 !help_contains_long_flag(&help, long),
6764 "security help must hide unsupported --{long}:\n{help}"
6765 );
6766 }
6767
6768 for long in [
6769 "root",
6770 "config",
6771 "format",
6772 "quiet",
6773 "no-cache",
6774 "threads",
6775 "changed-since",
6776 "diff-file",
6777 "diff-stdin",
6778 "workspace",
6779 "changed-workspaces",
6780 "ci",
6781 "fail-on-issues",
6782 "sarif-file",
6783 "summary",
6784 "output-file",
6785 "max-file-size",
6786 "explain",
6787 "surface",
6788 ] {
6789 assert!(
6790 help_contains_long_flag(&help, long),
6791 "security help must keep supported --{long}:\n{help}"
6792 );
6793 }
6794 }
6795
6796 #[test]
6797 fn security_help_detection_covers_subcommand_and_help_alias_forms() {
6798 assert_eq!(
6799 security_help_target(["security", "--help"]),
6800 Some(SecurityHelpTarget::Parent)
6801 );
6802 assert_eq!(
6803 security_help_target(["security", "-h"]),
6804 Some(SecurityHelpTarget::Parent)
6805 );
6806 assert_eq!(
6807 security_help_target(["--format", "json", "security", "--help"]),
6808 Some(SecurityHelpTarget::Parent)
6809 );
6810 assert_eq!(
6811 security_help_target(["help", "security"]),
6812 Some(SecurityHelpTarget::Parent)
6813 );
6814 assert_eq!(
6815 security_help_target(["security", "survivors", "--help"]),
6816 Some(SecurityHelpTarget::Survivors)
6817 );
6818 assert_eq!(
6819 security_help_target(["security", "survivors", "-h"]),
6820 Some(SecurityHelpTarget::Survivors)
6821 );
6822 assert_eq!(
6823 security_help_target(["help", "security", "survivors"]),
6824 Some(SecurityHelpTarget::Survivors)
6825 );
6826 assert_eq!(
6827 security_help_target(["security", "blind-spots", "--help"]),
6828 Some(SecurityHelpTarget::BlindSpots)
6829 );
6830 assert_eq!(
6831 security_help_target(["help", "security", "blind-spots"]),
6832 Some(SecurityHelpTarget::BlindSpots)
6833 );
6834 assert_eq!(security_help_target(["health", "--help"]), None);
6835 assert_eq!(security_help_target(["help", "health"]), None);
6836 }
6837
6838 #[test]
6839 fn security_unsupported_global_validator_matches_hidden_help_contract() {
6840 for (argv, expected) in [
6841 (vec!["fallow", "security", "--performance"], "--performance"),
6842 (
6843 vec!["fallow", "security", "--baseline", "base.json"],
6844 "--baseline",
6845 ),
6846 (
6847 vec!["fallow", "security", "--fail-on-stale-baseline"],
6848 "--fail-on-stale-baseline",
6849 ),
6850 (
6851 vec!["fallow", "security", "--dupes-mode", "weak"],
6852 "--dupes-mode",
6853 ),
6854 ] {
6855 let cli = Cli::try_parse_from(argv).expect("security global parses before validation");
6856 assert_eq!(unsupported_security_global(&cli), Some(expected));
6857 }
6858
6859 let explain = Cli::try_parse_from(["fallow", "security", "--explain"])
6860 .expect("security --explain parses");
6861 assert_eq!(unsupported_security_global(&explain), None);
6862 }
6863
6864 #[test]
6865 fn programmatic_common_options_track_analysis_affecting_cli_globals() {
6866 use clap::CommandFactory;
6867
6868 let cli_flags: std::collections::BTreeSet<String> = Cli::command()
6869 .get_arguments()
6870 .filter(|arg| arg.is_global_set())
6871 .filter_map(|arg| arg.get_long().map(str::to_owned))
6872 .filter(|name| {
6873 matches!(
6874 name.as_str(),
6875 "root"
6876 | "config"
6877 | "allow-remote-extends"
6878 | "no-cache"
6879 | "threads"
6880 | "changed-since"
6881 | "diff-file"
6882 | "production"
6883 | "workspace"
6884 | "changed-workspaces"
6885 | "explain"
6886 )
6887 })
6888 .collect();
6889 let programmatic_flags: std::collections::BTreeSet<String> =
6890 fallow_api::COMMON_ANALYSIS_OPTION_FLAGS
6891 .iter()
6892 .map(|flag| (*flag).to_owned())
6893 .collect();
6894
6895 assert_eq!(programmatic_flags, cli_flags);
6896 }
6897
6898 #[test]
6899 fn dead_code_registry_filter_flags_are_exposed_by_clap() {
6900 use clap::CommandFactory;
6901
6902 let cli = Cli::command();
6903 let dead_code = cli
6904 .get_subcommands()
6905 .find(|command| command.get_name() == "dead-code")
6906 .expect("dead-code subcommand is registered");
6907 let cli_flags: std::collections::BTreeSet<String> = dead_code
6908 .get_arguments()
6909 .filter_map(|arg| arg.get_long().map(|long| format!("--{long}")))
6910 .collect();
6911
6912 for flag in fallow_types::issue_meta::DEAD_CODE_FILTER_FLAGS.iter() {
6913 assert!(
6914 cli_flags.contains(*flag),
6915 "registry filter flag {flag} is missing from dead-code clap args"
6916 );
6917 }
6918 }
6919
6920 fn help_contains_long_flag(help: &str, long: &str) -> bool {
6921 let flag = format!("--{long}");
6922 help.split(|c: char| c.is_whitespace() || c == ',' || c == '[' || c == ']')
6923 .any(|token| token == flag)
6924 }
6925
6926 fn visit_help(cmd: &mut clap::Command, path: &str, violations: &mut Vec<(String, String)>) {
6927 let help = cmd.render_long_help().to_string();
6928 for line in scan_forbidden(&help) {
6929 violations.push((path.to_owned(), line));
6930 }
6931 let names: Vec<String> = cmd
6932 .get_subcommands()
6933 .map(|sub| sub.get_name().to_owned())
6934 .collect();
6935 for name in names {
6936 if name == "help" {
6937 continue;
6938 }
6939 if let Some(sub) = cmd.find_subcommand_mut(&name) {
6940 let sub_path = format!("{path} {name}");
6941 visit_help(sub, &sub_path, violations);
6942 }
6943 }
6944 }
6945
6946 fn scan_forbidden(s: &str) -> Vec<String> {
6947 let lower = s.to_ascii_lowercase();
6948 let mut out = Vec::new();
6949 for word in ["stub", "placeholder"] {
6950 if let Some(idx) = find_whole_word(&lower, word) {
6951 out.push(extract_line(s, idx));
6952 }
6953 }
6954 if let Some(idx) = lower.find("not yet") {
6955 out.push(extract_line(s, idx));
6956 }
6957 out
6958 }
6959
6960 fn find_whole_word(haystack: &str, word: &str) -> Option<usize> {
6961 let bytes = haystack.as_bytes();
6962 let mut start = 0;
6963 while let Some(rel) = haystack[start..].find(word) {
6964 let abs = start + rel;
6965 let before_ok = abs == 0 || !bytes[abs - 1].is_ascii_alphanumeric();
6966 let after_idx = abs + word.len();
6967 let after_ok = after_idx >= bytes.len() || !bytes[after_idx].is_ascii_alphanumeric();
6968 if before_ok && after_ok {
6969 return Some(abs);
6970 }
6971 start = abs + word.len();
6972 }
6973 None
6974 }
6975
6976 fn extract_line(s: &str, byte_idx: usize) -> String {
6977 let line_start = s[..byte_idx].rfind('\n').map_or(0, |i| i + 1);
6978 let line_end = s[byte_idx..].find('\n').map_or(s.len(), |i| byte_idx + i);
6979 s[line_start..line_end].trim().to_owned()
6980 }
6981
6982 #[test]
6983 fn emit_error_returns_given_exit_code() {
6984 let code = emit_error("test error", 2, fallow_config::OutputFormat::Human);
6985 assert_eq!(code, ExitCode::from(2));
6986 }
6987
6988 fn telemetry_run_for_mode(mode: telemetry::AnalysisMode) -> TelemetryRun {
6989 TelemetryRun {
6990 workflow: telemetry::Workflow::Health,
6991 output: fallow_config::OutputFormat::Json,
6992 quiet: true,
6993 start: std::time::Instant::now(),
6994 context: telemetry::WorkflowContext {
6995 run_scope: telemetry::RunScope::FullProject,
6996 config_shape: telemetry::ConfigShape::Default,
6997 output_destination: telemetry::OutputDestination::Stdout,
6998 analysis_mode: mode,
6999 },
7000 }
7001 }
7002
7003 #[test]
7004 fn fallback_failure_reason_skips_success_and_findings() {
7005 let run = telemetry_run_for_mode(telemetry::AnalysisMode::Static);
7006
7007 assert_eq!(fallback_failure_reason_for(&run, ExitCode::SUCCESS), None);
7008 assert_eq!(fallback_failure_reason_for(&run, ExitCode::from(1)), None);
7009 }
7010
7011 #[test]
7012 fn fallback_failure_reason_classifies_network_auth_and_analysis() {
7013 let static_run = telemetry_run_for_mode(telemetry::AnalysisMode::Static);
7014 let cloud_run = telemetry_run_for_mode(telemetry::AnalysisMode::ProductionCoverage);
7015
7016 assert_eq!(
7017 fallback_failure_reason_for(&static_run, ExitCode::from(api::NETWORK_EXIT_CODE)),
7018 Some(telemetry::FailureReason::Network),
7019 );
7020 assert_eq!(
7021 fallback_failure_reason_for(&static_run, ExitCode::from(12)),
7022 Some(telemetry::FailureReason::Auth),
7023 );
7024 assert_eq!(
7025 fallback_failure_reason_for(&cloud_run, ExitCode::from(3)),
7026 Some(telemetry::FailureReason::Auth),
7027 );
7028 assert_eq!(
7029 fallback_failure_reason_for(&static_run, ExitCode::from(2)),
7030 Some(telemetry::FailureReason::Analysis),
7031 );
7032 }
7033
7034 #[test]
7035 fn bare_coverage_flags_parse_without_subcommand() {
7036 let cli = Cli::try_parse_from([
7037 "fallow",
7038 "--coverage",
7039 "coverage/coverage-final.json",
7040 "--coverage-root",
7041 "/ci/workspace",
7042 ])
7043 .expect("bare combined coverage flags should parse");
7044 assert!(cli.command.is_none());
7045 assert_eq!(
7046 cli.coverage.as_deref(),
7047 Some(std::path::Path::new("coverage/coverage-final.json"))
7048 );
7049 assert_eq!(
7050 cli.coverage_root.as_deref(),
7051 Some(std::path::Path::new("/ci/workspace"))
7052 );
7053 }
7054
7055 #[test]
7056 fn bare_coverage_before_subcommand_is_detectable() {
7057 let cli = Cli::try_parse_from([
7058 "fallow",
7059 "--coverage",
7060 "coverage/coverage-final.json",
7061 "dead-code",
7062 ])
7063 .expect("clap should parse pre-subcommand bare coverage for custom rejection");
7064 assert!(cli.command.is_some());
7065 assert!(cli_has_bare_coverage_input(&cli));
7066 let message = bare_coverage_subcommand_error_message();
7067 assert!(message.contains("bare combined-mode flags"));
7068 assert!(message.contains("fallow health --coverage <coverage-final.json>"));
7069 }
7070
7071 #[test]
7072 fn subcommand_coverage_flag_keeps_regular_clap_error() {
7073 let Err(err) = Cli::try_parse_from(["fallow", "dead-code", "--coverage"]) else {
7074 panic!("dead-code --coverage should fail to parse");
7075 };
7076 assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
7077 }
7078
7079 #[test]
7080 fn type_aware_flags_parse_for_semantic_analysis() {
7081 let cli = Cli::try_parse_from([
7082 "fallow",
7083 "dead-code",
7084 "--unused-class-members",
7085 "--type-aware",
7086 "--type-aware-project",
7087 "tsconfig.json",
7088 "--type-aware-project",
7089 "packages/web/tsconfig.json",
7090 ])
7091 .expect("type-aware flag should parse");
7092 assert!(cli.type_aware);
7093 assert_eq!(
7094 cli.type_aware_project,
7095 [
7096 PathBuf::from("tsconfig.json"),
7097 PathBuf::from("packages/web/tsconfig.json")
7098 ]
7099 );
7100 let Some(Command::Check {
7101 unused_class_members,
7102 ..
7103 }) = cli.command
7104 else {
7105 panic!("dead-code should parse as the check command");
7106 };
7107 assert!(unused_class_members);
7108 }
7109
7110 #[test]
7111 fn no_type_aware_conflicts_with_type_aware() {
7112 let Err(err) = Cli::try_parse_from(["fallow", "audit", "--type-aware", "--no-type-aware"])
7113 else {
7114 panic!("--no-type-aware must conflict with --type-aware");
7115 };
7116 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
7117 }
7118
7119 #[test]
7120 fn no_type_aware_forces_semantic_analysis_off() {
7121 let cli = Cli::try_parse_from(["fallow", "audit", "--no-type-aware"])
7122 .expect("--no-type-aware should parse on audit");
7123 assert_eq!(cli.type_aware_override(), Some(false));
7124
7125 let cli = Cli::try_parse_from(["fallow", "dead-code", "--type-aware"])
7126 .expect("--type-aware should parse");
7127 assert_eq!(cli.type_aware_override(), Some(true));
7128
7129 let cli = Cli::try_parse_from(["fallow", "dead-code"]).expect("bare command should parse");
7130 assert_eq!(cli.type_aware_override(), None);
7131 }
7132
7133 #[test]
7134 fn type_aware_status_output_hides_host_paths() {
7135 let root = Path::new("/private/work/project");
7136 let output = type_aware_status_output(
7137 root,
7138 fallow_api::TypeAwareStatus {
7139 available: false,
7140 discovery_source: Some("environment-override"),
7141 companion_path: Some(PathBuf::from("/private/tools/fallow-type-aware")),
7142 package_version: None,
7143 protocol_version: 7,
7144 backend_family: None,
7145 backend_version: None,
7146 remediation: Some(
7147 "failed to launch /private/tools/fallow-type-aware from /private/work/project"
7148 .to_string(),
7149 ),
7150 },
7151 );
7152
7153 assert_eq!(
7154 output.schema_version.0,
7155 fallow_output::TYPE_AWARE_STATUS_SCHEMA_VERSION
7156 );
7157 assert_eq!(output.companion_path.as_deref(), Some("fallow-type-aware"));
7158 let remediation = output.remediation.expect("remediation");
7159 assert!(!remediation.contains("/private/"));
7160 assert!(remediation.contains("fallow-type-aware"));
7161 }
7162
7163 #[test]
7164 fn format_parsing_covers_all_variants() {
7165 assert!(matches!(parse_format_arg("json"), Some(Format::Json)));
7166 assert!(matches!(parse_format_arg("JSON"), Some(Format::Json)));
7167 assert!(matches!(parse_format_arg("human"), Some(Format::Human)));
7168 assert!(matches!(parse_format_arg("sarif"), Some(Format::Sarif)));
7169 assert!(matches!(parse_format_arg("compact"), Some(Format::Compact)));
7170 assert!(matches!(
7171 parse_format_arg("markdown"),
7172 Some(Format::Markdown)
7173 ));
7174 assert!(matches!(parse_format_arg("md"), Some(Format::Markdown)));
7175 assert!(matches!(
7176 parse_format_arg("codeclimate"),
7177 Some(Format::CodeClimate)
7178 ));
7179 assert!(matches!(
7180 parse_format_arg("gitlab-codequality"),
7181 Some(Format::CodeClimate)
7182 ));
7183 assert!(matches!(
7184 parse_format_arg("gitlab-code-quality"),
7185 Some(Format::CodeClimate)
7186 ));
7187 assert!(matches!(
7188 parse_format_arg("pr-comment-github"),
7189 Some(Format::PrCommentGithub)
7190 ));
7191 assert!(matches!(
7192 parse_format_arg("pr-comment-gitlab"),
7193 Some(Format::PrCommentGitlab)
7194 ));
7195 assert!(matches!(
7196 parse_format_arg("review-github"),
7197 Some(Format::ReviewGithub)
7198 ));
7199 assert!(matches!(
7200 parse_format_arg("review-gitlab"),
7201 Some(Format::ReviewGitlab)
7202 ));
7203 assert!(matches!(parse_format_arg("badge"), Some(Format::Badge)));
7204 assert!(parse_format_arg("xml").is_none());
7205 assert!(parse_format_arg("").is_none());
7206 }
7207
7208 #[test]
7209 fn quiet_parsing_logic() {
7210 let parse = |s: &str| -> bool { s == "1" || s.eq_ignore_ascii_case("true") };
7211 assert!(parse("1"));
7212 assert!(parse("true"));
7213 assert!(parse("TRUE"));
7214 assert!(parse("True"));
7215 assert!(!parse("0"));
7216 assert!(!parse("false"));
7217 assert!(!parse("yes"));
7218 }
7219
7220 #[test]
7221 fn tracing_filter_defaults_to_warn_without_env() {
7222 assert_eq!(build_tracing_filter(None).to_string(), "warn");
7223 }
7224
7225 #[test]
7226 fn tracing_filter_respects_explicit_env_directives() {
7227 assert_eq!(build_tracing_filter(Some("info")).to_string(), "info");
7228 }
7229
7230 #[test]
7231 fn tracing_filter_treats_empty_env_as_off() {
7232 assert_eq!(build_tracing_filter(Some("")).to_string(), "off");
7233 assert_eq!(build_tracing_filter(Some(" ")).to_string(), "off");
7234 }
7235}