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