1#![expect(
2 clippy::print_stdout,
3 clippy::print_stderr,
4 reason = "CLI binary produces intentional terminal output"
5)]
6#![cfg_attr(
7 test,
8 allow(
9 clippy::unwrap_used,
10 clippy::expect_used,
11 reason = "tests use unwrap and expect to keep fixture setup concise"
12 )
13)]
14
15use std::io::IsTerminal as _;
16use std::path::{Path, PathBuf};
17use std::process::ExitCode;
18
19use clap::{Parser, Subcommand};
20
21mod api;
22#[cfg(test)]
23mod architecture_boundaries;
24mod audit;
25mod audit_brief;
26mod audit_cache_prune;
27mod audit_decision_surface;
28mod audit_focus;
29mod audit_walkthrough;
30mod base_worktree;
31pub use base_worktree::canonical_root_hash;
35mod walkthrough_state;
36use fallow_engine::baseline;
37mod agent_install;
38mod baseline_gate;
39mod baseline_growth;
40mod cache_notice;
41mod check;
42mod ci;
43mod ci_template;
44mod claude_code_hint;
45mod cli_agent;
46mod cli_format;
47mod cli_hooks;
48mod cli_impact;
49mod cli_production;
50mod cli_report;
51mod cli_startup;
52pub use fallow_engine::codeowners;
53mod combined;
54mod config;
55mod count_delta;
56mod coverage;
57mod discovery_note;
58mod doctor;
59mod dupes;
60mod exit_codes;
61pub mod explain;
62mod fix;
63mod flags;
64mod flags_retirement_formats;
65mod gates;
66mod guard;
67mod health;
68mod impact;
69mod init;
70mod inspect;
71mod json_style;
72mod license;
73mod list;
74mod migrate;
75mod onboarding;
76#[cfg(test)]
77mod output_envelope;
78mod output_runtime;
79mod path_util;
80mod plugin_check;
81mod process_clock;
82mod rayon_pool;
83mod regression;
84pub mod report;
85mod requests;
86mod rule_pack;
87mod runtime_support;
88mod schema;
89mod scope_path;
90mod security;
91mod security_help;
92mod selector;
93mod setup_hooks;
94mod signal;
95mod similar_code_cli;
96mod similar_code_help;
97mod suppressions;
98mod task_matrix;
99mod telemetry;
100mod trace_chain;
101mod trace_error;
102mod trace_path;
103mod type_aware_degrade;
104mod update_check;
105use fallow_engine::validate;
106use fallow_engine::vital_signs;
107mod cli_telemetry;
108mod viz;
109mod watch;
110mod write_scope;
111
112use check::{CheckOptions, IssueFilters, TraceOptions};
113pub(crate) mod error;
115use cli_agent::{AgentCli, run_agent_command};
116#[cfg(test)]
117use cli_format::parse_format_arg;
118use cli_format::{Format, FormatConfig};
119use cli_hooks::{HooksCli, run_hooks_command};
120use cli_impact::{ImpactCli, ImpactCrossRepoOpts, ImpactSortCli, dispatch_impact};
121use cli_production::{ProductionModes, resolve_production_modes};
122#[cfg(test)]
123use cli_startup::build_tracing_filter;
124use cli_startup::{
125 bare_combined_baseline_subcommand_error_message, bare_coverage_subcommand_error_message,
126 cli_bare_combined_baseline_flag, cli_has_bare_coverage_input, parse_cli_args,
127 reject_global_baseline_flags, run_pre_dispatch_checks, setup_tracing, validate_inputs,
128};
129#[cfg(test)]
130use cli_telemetry::TelemetryRun;
131#[cfg(test)]
132use cli_telemetry::{fallback_failure_reason_for, telemetry_workflow_for_command};
133use cli_telemetry::{record_run_epilogue, start_telemetry_run};
134use dupes::{DupesMode, DupesOptions, DupesOverrides};
135use error::emit_error;
136use health::{HealthOptions, SortBy};
137use list::ListOptions;
138pub(crate) use runtime_support::{AnalysisKind, GroupBy};
139pub(crate) use runtime_support::{
140 ConfigLoadOptions, LoadConfigArgs, build_ownership_resolver, load_config,
141 load_config_for_analysis,
142};
143#[cfg(test)]
144use security_help::{SECURITY_UNSUPPORTED_GLOBAL_LONGS, SecurityHelpTarget};
145use security_help::{render_security_help, security_help_target};
146use similar_code_help::{render_similar_code_help, similar_code_help_target};
147
148const DEFAULT_MIN_INVOCATIONS_HOT: u64 = 100;
149
150const TOP_LEVEL_HELP_TEMPLATE: &str =
151 "{about-with-newline}\n{usage-heading} {usage}{after-help}\n\nOptions:\n{options}";
152
153macro_rules! top_level_task_cheat_sheet {
156 () => {
157 "\
158When the agent is about to...
159 delete an \"unused\" export or file fallow dead-code --trace <file>:<export>
160 prove exact TypeScript symbol consumers fallow dead-code --type-aware --symbol-impact <file>:<export-or-class.method>
161 find how one module reaches another fallow trace --path <from> <to>
162 delete an \"unused\" dependency fallow dead-code --trace-dependency <name>
163 commit or open a PR fallow audit --base <ref>
164 read a diff before approving it fallow review --base <ref> --brief
165 prioritize refactoring fallow health --hotspots --targets
166 ask who owns code fallow health --ownership
167 check untested-but-reachable code fallow health --coverage-gaps
168 consolidate duplication fallow dupes --trace dup:<fingerprint>
169 find feature flags fallow flags
170 check architecture rules before editing fallow guard <files>
171 surface security candidates fallow security
172 inspect a target before editing fallow inspect --file <path>
173 understand a finding fallow explain <issue-type>
174 scope a monorepo --workspace <glob> / --changed-workspaces <ref>"
175 };
176}
177
178macro_rules! top_level_core_command_groups {
179 () => {
180 "\
181Analysis:
182 dead-code Analyze unused code, dependency hygiene, and architecture cycles
183 dupes Find copy-paste and structural code duplication
184 health Analyze complexity, maintainability, hotspots, and coverage gaps
185 flags Detect feature flag usage patterns
186 security Surface local security candidates for agent verification (opt-in)
187 similar-code Find semantic implementation overlap for verification (opt-in, local)
188 audit Review changed files for dead code, complexity, duplication, and styling
189
190Workflow:
191 watch Re-run analysis as files change
192 fix Auto-fix safe unused-code findings"
193 };
194}
195
196macro_rules! top_level_extended_command_groups {
197 () => {
198 "\
199Project inspection:
200 list List discovered files, entry points, plugins, boundaries, workspaces, and entry weight
201 inspect Inspect one file or exported symbol as a bundled evidence query
202 trace Trace a symbol's call chain (best-effort, syntactic)
203 trace-error Resolve a runtime stack trace's frames to project definitions
204 guard Show which architecture rules apply to files before editing
205 decision-surface Surface the structural decisions a change embeds (advisory)
206 workspaces Show monorepo workspace discovery diagnostics
207 explain Explain one issue type without running analysis
208 suppressions List active fallow-ignore suppression markers
209 impact Show what fallow has done for you (opt-in, local-only)
210 viz Generate an interactive HTML map of the codebase
211
212Setup and configuration:
213 doctor Diagnose project readiness without changing anything
214 init Create a fallow config, optionally with a Git hook
215 agent Wire fallow into Claude Code, Codex, or Cursor in one pass
216 audit-cache Maintain reusable audit base-snapshot caches
217 recommend Recommend a project-tailored config for an agent to author
218 migrate Migrate knip, jscpd, or stylelint config to fallow
219 config Show the resolved config and loaded config file
220 config-schema Print the fallow config JSON Schema
221 plugin-schema Print the external plugin JSON Schema
222 plugin-check Dry-run external plugins and report what they seed
223 rule-pack Manage declarative rule packs (policy-as-code)
224 rule-pack-schema Print the rule pack JSON Schema
225 type-aware Inspect the optional TypeScript semantic companion
226
227Automation and CI:
228 ci Build PR/MR feedback envelopes
229 ci-template Print or vendor CI integration templates
230 report Re-render saved JSON as GitHub or CodeClimate output
231 hooks Install or remove fallow-managed Git and agent hooks
232 setup-hooks Deprecated: use `agent install` or `hooks install --target agent`
233
234Runtime coverage:
235 coverage Set up or analyze runtime coverage data
236 license Manage the paid-feature license
237 telemetry Manage opt-in product telemetry
238
239Reference:
240 schema Dump the CLI interface as machine-readable JSON
241 help Print this message or the help of a command"
242 };
243}
244
245const TOP_LEVEL_AFTER_HELP: &str = concat!(
246 top_level_task_cheat_sheet!(),
247 "\n\n",
248 top_level_core_command_groups!(),
249 "\n\nRun fallow --help for the complete command list."
250);
251
252const TOP_LEVEL_AFTER_LONG_HELP: &str = concat!(
253 top_level_task_cheat_sheet!(),
254 "\n\n",
255 top_level_core_command_groups!(),
256 "\n\n",
257 top_level_extended_command_groups!(),
258 "\n\n",
259 "When no command is given, fallow runs dead-code + dupes + health together.\n",
260 "Use --only/--skip to select specific analyses."
261);
262
263#[derive(Parser)]
264#[command(
265 name = "fallow",
266 about = "Codebase analyzer for TypeScript/JavaScript: unused code, circular dependencies, code duplication, complexity hotspots, and architecture boundary violations",
267 version,
268 disable_version_flag = true,
269 help_template = TOP_LEVEL_HELP_TEMPLATE,
270 after_help = TOP_LEVEL_AFTER_HELP,
271 after_long_help = TOP_LEVEL_AFTER_LONG_HELP
272)]
273struct Cli {
274 #[command(subcommand)]
275 command: Option<Command>,
276
277 #[arg(value_name = "PATH")]
280 path: Option<PathBuf>,
281
282 #[arg(
286 short = 'v',
287 visible_short_alias = 'V',
288 long = "version",
289 action = clap::ArgAction::Version
290 )]
291 version: Option<bool>,
292
293 #[arg(short, long, global = true)]
295 root: Option<PathBuf>,
296
297 #[arg(short, long, global = true)]
299 config: Option<PathBuf>,
300
301 #[arg(hide_short_help = true, long, global = true)]
303 allow_remote_extends: bool,
304
305 #[arg(
307 short,
308 long,
309 visible_alias = "output",
310 global = true,
311 default_value = "human"
312 )]
313 format: Format,
314
315 #[arg(hide_short_help = true, long, global = true)]
317 pretty: bool,
318
319 #[arg(short, long, global = true)]
321 quiet: bool,
322
323 #[arg(hide_short_help = true, long, global = true)]
325 no_cache: bool,
326
327 #[arg(hide_short_help = true, long, global = true)]
329 threads: Option<usize>,
330
331 #[arg(long, visible_alias = "base", global = true)]
336 changed_since: Option<String>,
337
338 #[arg(hide_short_help = true, long, global = true)]
344 no_package_baselines: bool,
345
346 #[arg(
351 hide_short_help = true,
352 long = "diff-file",
353 value_name = "PATH",
354 global = true
355 )]
356 diff_file: Option<PathBuf>,
357
358 #[arg(hide_short_help = true, long = "diff-stdin", global = true)]
361 diff_stdin: bool,
362
363 #[arg(
370 hide_short_help = true,
371 long = "churn-file",
372 value_name = "PATH",
373 global = true
374 )]
375 churn_file: Option<PathBuf>,
376
377 #[arg(
384 hide_short_help = true,
385 long = "max-file-size",
386 value_name = "MB",
387 global = true
388 )]
389 max_file_size: Option<u32>,
390
391 #[arg(hide_short_help = true, long, global = true)]
394 baseline: Option<PathBuf>,
395
396 #[arg(
411 hide_short_help = true,
412 long = "baseline-mode",
413 value_enum,
414 global = true
415 )]
416 baseline_mode: Option<BaselineModeArg>,
417
418 #[arg(long, global = true, value_name = "RUN_ID", hide = true)]
424 parent_run: Option<String>,
425
426 #[arg(hide_short_help = true, long, global = true)]
430 save_baseline: Option<PathBuf>,
431
432 #[arg(long, global = true)]
435 production: bool,
436
437 #[arg(
441 hide_short_help = true,
442 long = "no-production",
443 global = true,
444 conflicts_with = "production"
445 )]
446 no_production: bool,
447
448 #[arg(hide_short_help = true, long = "production-dead-code")]
450 production_dead_code: bool,
451
452 #[arg(hide_short_help = true, long = "production-health")]
454 production_health: bool,
455
456 #[arg(hide_short_help = true, long = "production-dupes")]
458 production_dupes: bool,
459
460 #[arg(short, long, global = true, value_delimiter = ',')]
464 workspace: Option<Vec<String>>,
465
466 #[arg(long, global = true, value_name = "REF")]
469 changed_workspaces: Option<String>,
470
471 #[arg(hide_short_help = true, long, global = true)]
473 group_by: Option<GroupBy>,
474
475 #[arg(
480 hide_short_help = true,
481 long,
482 global = true,
483 value_delimiter = ',',
484 value_name = "KEY",
485 requires = "group_by"
486 )]
487 group: Option<Vec<String>>,
488
489 #[arg(hide_short_help = true, long, global = true)]
491 performance: bool,
492
493 #[arg(hide_short_help = true, long, global = true)]
495 explain: bool,
496
497 #[arg(hide_short_help = true, long, global = true)]
500 explain_skipped: bool,
501
502 #[arg(hide_short_help = true, long, global = true)]
504 summary: bool,
505
506 #[arg(long, global = true)]
508 ci: bool,
509
510 #[arg(hide_short_help = true, long, global = true)]
512 fail_on_issues: bool,
513
514 #[arg(hide_short_help = true, long, global = true, value_name = "PATH")]
519 sarif_file: Option<PathBuf>,
520
521 #[arg(short = 'o', long, global = true, value_name = "PATH")]
527 output_file: Option<PathBuf>,
528
529 #[arg(
539 hide_short_help = true,
540 long = "report-path-prefix",
541 visible_alias = "annotations-path-prefix",
542 global = true,
543 value_name = "PREFIX"
544 )]
545 report_path_prefix: Option<String>,
546
547 #[arg(hide_short_help = true, long, global = true)]
553 fail_on_regression: bool,
554
555 #[arg(hide_short_help = true, long, global = true)]
569 fail_on_stale_baseline: bool,
570
571 #[arg(hide_short_help = true, long, global = true)]
586 fail_on_baseline_growth: bool,
587
588 #[arg(hide_short_help = true, long, global = true, value_name = "REF")]
593 baseline_base: Option<String>,
594
595 #[arg(hide_short_help = true, long, global = true)]
605 fail_on_parse_error: bool,
606
607 #[arg(
613 hide_short_help = true,
614 long,
615 global = true,
616 value_name = "TOLERANCE",
617 default_value = "0"
618 )]
619 tolerance: String,
620
621 #[arg(hide_short_help = true, long, global = true, value_name = "PATH")]
623 regression_baseline: Option<PathBuf>,
624
625 #[expect(
630 clippy::option_option,
631 reason = "clap pattern: None=not passed, Some(None)=flag only (write to config), Some(Some(path))=write to file"
632 )]
633 #[arg(hide_short_help = true, long, global = true, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
634 save_regression_baseline: Option<Option<String>>,
635
636 #[arg(long, value_delimiter = ',')]
638 only: Vec<AnalysisKind>,
639
640 #[arg(long, value_delimiter = ',')]
642 skip: Vec<AnalysisKind>,
643
644 #[arg(hide_short_help = true, long = "dupes-mode", global = true)]
646 dupes_mode: Option<DupesMode>,
647
648 #[arg(hide_short_help = true, long = "dupes-near", global = true)]
650 dupes_near: bool,
651
652 #[arg(hide_short_help = true, long = "dupes-threshold", global = true)]
654 dupes_threshold: Option<f64>,
655
656 #[arg(hide_short_help = true, long = "dupes-min-tokens", global = true)]
658 dupes_min_tokens: Option<usize>,
659
660 #[arg(hide_short_help = true, long = "dupes-min-lines", global = true)]
662 dupes_min_lines: Option<usize>,
663
664 #[arg(hide_short_help = true, long = "dupes-min-occurrences", global = true, value_parser = parse_min_occurrences)]
666 dupes_min_occurrences: Option<usize>,
667
668 #[arg(hide_short_help = true, long = "dupes-skip-local", global = true)]
670 dupes_skip_local: bool,
671
672 #[arg(hide_short_help = true, long = "dupes-cross-language", global = true)]
674 dupes_cross_language: bool,
675
676 #[arg(hide_short_help = true, long = "dupes-ignore-imports", global = true)]
679 dupes_ignore_imports: bool,
680
681 #[arg(
684 hide_short_help = true,
685 long = "dupes-no-ignore-imports",
686 global = true,
687 conflicts_with = "dupes_ignore_imports"
688 )]
689 dupes_no_ignore_imports: bool,
690
691 #[arg(hide_short_help = true, long = "dupes-ignore-symlinks", global = true)]
694 dupes_ignore_symlinks: bool,
695
696 #[arg(
699 hide_short_help = true,
700 long = "dupes-no-ignore-symlinks",
701 global = true,
702 conflicts_with = "dupes_ignore_symlinks"
703 )]
704 dupes_no_ignore_symlinks: bool,
705
706 #[arg(hide_short_help = true, long)]
708 score: bool,
709
710 #[arg(hide_short_help = true, long)]
712 trend: bool,
713
714 #[arg(hide_short_help = true, long, value_name = "PATH")]
717 trend_from: Option<PathBuf>,
718
719 #[expect(
723 clippy::option_option,
724 reason = "clap pattern: None=not passed, Some(None)=default path, Some(Some(path))=custom path"
725 )]
726 #[arg(hide_short_help = true, long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
727 save_snapshot: Option<Option<String>>,
728
729 #[arg(hide_short_help = true, long, value_name = "PATH")]
732 coverage: Option<PathBuf>,
733
734 #[arg(hide_short_help = true, long = "coverage-root", value_name = "PATH")]
737 coverage_root: Option<PathBuf>,
738
739 #[arg(hide_short_help = true, long = "dupes-baseline", value_name = "PATH")]
742 dupes_baseline: Option<PathBuf>,
743
744 #[arg(hide_short_help = true, long = "health-baseline", value_name = "PATH")]
747 health_baseline: Option<PathBuf>,
748
749 #[arg(hide_short_help = true, long, global = true)]
751 include_entry_exports: bool,
752
753 #[arg(hide_short_help = true, long, global = true)]
756 type_aware: bool,
757
758 #[arg(
761 hide_short_help = true,
762 long,
763 global = true,
764 conflicts_with = "type_aware"
765 )]
766 no_type_aware: bool,
767
768 #[arg(hide_short_help = true, long, global = true, value_name = "PATH", action = clap::ArgAction::Append)]
770 type_aware_project: Vec<PathBuf>,
771
772 #[arg(hide_short_help = true, long, global = true, value_enum)]
774 type_aware_require: Option<TypeAwareRequireArg>,
775}
776
777impl Cli {
778 const fn type_aware_override(&self) -> Option<bool> {
782 if self.no_type_aware {
783 Some(false)
784 } else if self.type_aware {
785 Some(true)
786 } else {
787 None
788 }
789 }
790
791 const fn dupes_overrides(&self) -> DupesOverrides {
793 DupesOverrides {
794 mode: self.dupes_mode,
795 near: self.dupes_near,
796 threshold: self.dupes_threshold,
797 min_tokens: self.dupes_min_tokens,
798 min_lines: self.dupes_min_lines,
799 min_occurrences: self.dupes_min_occurrences,
800 skip_local: self.dupes_skip_local,
801 ignore_symlinks: resolve_negatable_flag(
802 self.dupes_ignore_symlinks,
803 self.dupes_no_ignore_symlinks,
804 ),
805 cross_language: self.dupes_cross_language,
806 ignore_imports: resolve_negatable_flag(
807 self.dupes_ignore_imports,
808 self.dupes_no_ignore_imports,
809 ),
810 }
811 }
812}
813
814#[derive(Clone, Copy, Subcommand)]
815enum TypeAwareCli {
816 Status,
818}
819
820#[derive(Subcommand)]
821enum Command {
822 #[command(name = "dead-code", alias = "check")]
824 Check {
825 #[arg(long)]
827 unused_files: bool,
828
829 #[arg(long)]
831 unused_exports: bool,
832
833 #[arg(long)]
835 unused_deps: bool,
836
837 #[arg(long)]
839 unused_types: bool,
840
841 #[arg(long)]
843 private_type_leaks: bool,
844
845 #[arg(long)]
847 deprecated_exports_in_use: bool,
848
849 #[arg(long)]
851 unused_enum_members: bool,
852
853 #[arg(long)]
855 unused_class_members: bool,
856
857 #[arg(long)]
859 unused_store_members: bool,
860
861 #[arg(long)]
863 unprovided_injects: bool,
864
865 #[arg(long)]
867 unrendered_components: bool,
868
869 #[arg(long)]
871 unused_component_props: bool,
872
873 #[arg(long)]
875 absent_component_props: bool,
876
877 #[arg(long)]
879 unused_component_emits: bool,
880
881 #[arg(long)]
883 unused_component_inputs: bool,
884
885 #[arg(long)]
887 unused_component_outputs: bool,
888
889 #[arg(long)]
891 unused_svelte_events: bool,
892
893 #[arg(long)]
895 unused_server_actions: bool,
896
897 #[arg(long)]
899 unused_load_data_keys: bool,
900
901 #[arg(long)]
903 unresolved_imports: bool,
904
905 #[arg(long)]
907 unlisted_deps: bool,
908
909 #[arg(long)]
911 duplicate_exports: bool,
912
913 #[arg(long)]
915 circular_deps: bool,
916
917 #[arg(long)]
919 re_export_cycles: bool,
920
921 #[arg(long)]
923 package_cycles: bool,
924
925 #[arg(long)]
927 boundary_violations: bool,
928
929 #[arg(long)]
931 policy_violations: bool,
932
933 #[arg(long)]
935 stale_suppressions: bool,
936
937 #[arg(long)]
939 unused_catalog_entries: bool,
940
941 #[arg(long)]
943 empty_catalog_groups: bool,
944
945 #[arg(long)]
947 unresolved_catalog_references: bool,
948
949 #[arg(long)]
951 unused_dependency_overrides: bool,
952
953 #[arg(long)]
955 misconfigured_dependency_overrides: bool,
956
957 #[arg(long)]
959 include_dupes: bool,
960
961 #[arg(long, value_name = "FILE:EXPORT")]
963 trace: Option<String>,
964
965 #[arg(long, value_name = "PATH")]
967 trace_file: Option<String>,
968
969 #[arg(long, value_name = "PACKAGE")]
971 trace_dependency: Option<String>,
972
973 #[arg(long, value_name = "PATH")]
977 impact_closure: Option<String>,
978
979 #[arg(long, value_name = "FILE:EXPORT")]
981 symbol_impact: Option<String>,
982
983 #[arg(long)]
988 top: Option<usize>,
989
990 #[arg(long, value_name = "PATH")]
994 file: Vec<std::path::PathBuf>,
995
996 #[arg(long = "finding-id", value_name = "ID", value_delimiter = ',')]
1001 finding_id: Vec<String>,
1002
1003 #[arg(value_name = "PATH")]
1006 path: Option<std::path::PathBuf>,
1007 },
1008
1009 Watch {
1011 #[arg(long)]
1013 no_clear: bool,
1014 },
1015
1016 TypeAware {
1018 #[command(subcommand)]
1019 subcommand: TypeAwareCli,
1020 },
1021
1022 #[command(override_help = doctor::HELP)]
1029 Doctor,
1030
1031 SimilarCode {
1037 #[command(subcommand)]
1038 subcommand: Option<similar_code_cli::SimilarCodeSubcommand>,
1039 #[arg(long, value_name = "0..1")]
1041 threshold: Option<f64>,
1042 #[arg(long, value_name = "N")]
1044 min_lines: Option<usize>,
1045 #[arg(long, value_name = "N")]
1047 top: Option<usize>,
1048 #[arg(long, value_name = "PATH")]
1050 file: Vec<PathBuf>,
1051
1052 #[arg(value_name = "PATH")]
1055 path: Option<PathBuf>,
1056 },
1057
1058 Inspect {
1060 #[arg(
1062 long,
1063 value_name = "PATH",
1064 conflicts_with = "symbol",
1065 required_unless_present = "symbol"
1066 )]
1067 file: Option<String>,
1068
1069 #[arg(long, value_name = "FILE:EXPORT", conflicts_with = "file")]
1071 symbol: Option<String>,
1072
1073 #[arg(long)]
1078 symbol_chain: bool,
1079
1080 #[arg(long)]
1083 churn: bool,
1084 },
1085
1086 Trace {
1101 #[arg(value_name = "FILE:SYMBOL", required_unless_present = "path")]
1104 symbol: Option<String>,
1105
1106 #[arg(
1110 long,
1111 num_args = 2,
1112 value_names = ["FROM", "TO"],
1113 conflicts_with_all = ["symbol", "callers", "callees", "depth"]
1114 )]
1115 path: Vec<String>,
1116
1117 #[arg(long, requires = "path")]
1123 eager_only: bool,
1124
1125 #[arg(long)]
1128 callers: bool,
1129
1130 #[arg(long)]
1133 callees: bool,
1134
1135 #[arg(long, value_name = "N")]
1138 depth: Option<u32>,
1139 },
1140
1141 #[command(name = "trace-error")]
1160 TraceError {
1161 #[arg(value_name = "FILE")]
1165 trace_file: Option<String>,
1166 },
1167
1168 Fix {
1183 #[arg(long)]
1185 dry_run: bool,
1186
1187 #[arg(long, alias = "force")]
1189 yes: bool,
1190
1191 #[arg(long)]
1198 no_create_config: bool,
1199
1200 #[arg(value_name = "PATH")]
1204 path: Option<PathBuf>,
1205 },
1206
1207 Init {
1216 #[arg(long)]
1218 toml: bool,
1219
1220 #[arg(long, conflicts_with_all = ["toml", "hooks", "branch"])]
1222 agents: bool,
1223
1224 #[arg(long)]
1228 hooks: bool,
1229
1230 #[arg(long, requires = "hooks")]
1232 branch: Option<String>,
1233
1234 #[arg(long, conflicts_with_all = ["toml", "agents", "hooks", "branch"])]
1238 decline: bool,
1239 },
1240
1241 Hooks {
1248 #[command(subcommand)]
1249 subcommand: HooksCli,
1250 },
1251
1252 Agent {
1258 #[command(subcommand)]
1259 subcommand: AgentCli,
1260 },
1261
1262 Ci {
1264 #[command(subcommand)]
1265 subcommand: CiCli,
1266 },
1267
1268 ConfigSchema,
1270
1271 PluginSchema,
1273
1274 PluginCheck,
1276
1277 RulePackSchema,
1279
1280 RulePack {
1282 #[command(subcommand)]
1283 subcommand: RulePackCli,
1284 },
1285
1286 Guard {
1288 #[arg(required = true, num_args = 1..)]
1290 files: Vec<String>,
1291 },
1292
1293 Config {
1311 #[arg(long)]
1313 path: bool,
1314 },
1315
1316 Recommend,
1324
1325 List {
1328 #[arg(long)]
1330 entry_points: bool,
1331
1332 #[arg(long)]
1334 files: bool,
1335
1336 #[arg(long)]
1338 plugins: bool,
1339
1340 #[arg(long)]
1342 boundaries: bool,
1343
1344 #[arg(long)]
1348 workspaces: bool,
1349
1350 #[arg(long)]
1360 entry_weight: bool,
1361
1362 #[arg(value_name = "PATH")]
1365 path: Option<PathBuf>,
1366 },
1367
1368 Workspaces,
1374
1375 Dupes {
1377 #[arg(long)]
1380 mode: Option<DupesMode>,
1381
1382 #[arg(long)]
1384 near: bool,
1385
1386 #[arg(long)]
1389 min_tokens: Option<usize>,
1390
1391 #[arg(long)]
1394 min_lines: Option<usize>,
1395
1396 #[arg(long, value_parser = parse_min_occurrences)]
1401 min_occurrences: Option<usize>,
1402
1403 #[arg(long)]
1406 threshold: Option<f64>,
1407
1408 #[arg(long)]
1410 skip_local: bool,
1411
1412 #[arg(long)]
1414 cross_language: bool,
1415
1416 #[arg(long)]
1420 ignore_imports: bool,
1421
1422 #[arg(long, conflicts_with = "ignore_imports")]
1425 no_ignore_imports: bool,
1426
1427 #[arg(long)]
1432 ignore_symlinks: bool,
1433
1434 #[arg(long, conflicts_with = "ignore_symlinks")]
1437 no_ignore_symlinks: bool,
1438
1439 #[arg(long)]
1449 top: Option<usize>,
1450
1451 #[arg(long)]
1455 no_fragments: bool,
1456
1457 #[arg(long, value_name = "FILE:LINE")]
1459 trace: Option<String>,
1460
1461 #[arg(value_name = "PATH")]
1464 path: Option<PathBuf>,
1465 },
1466
1467 Health {
1473 #[arg(long)]
1475 max_cyclomatic: Option<u16>,
1476
1477 #[arg(long)]
1479 max_cognitive: Option<u16>,
1480
1481 #[arg(long)]
1485 max_crap: Option<f64>,
1486
1487 #[arg(long)]
1489 top: Option<usize>,
1490
1491 #[arg(long, default_value = "cyclomatic")]
1493 sort: SortBy,
1494
1495 #[arg(long)]
1498 complexity: bool,
1499
1500 #[arg(long)]
1507 complexity_breakdown: bool,
1508
1509 #[arg(long)]
1514 file_scores: bool,
1515
1516 #[arg(long)]
1519 coverage_gaps: bool,
1520
1521 #[arg(long)]
1524 hotspots: bool,
1525
1526 #[arg(long)]
1530 ownership: bool,
1531
1532 #[arg(long, value_name = "MODE", value_enum)]
1537 ownership_emails: Option<EmailModeArg>,
1538
1539 #[arg(long)]
1542 targets: bool,
1543
1544 #[arg(long)]
1547 type_coupling: bool,
1548
1549 #[arg(long)]
1554 css: bool,
1555
1556 #[arg(long, value_enum)]
1559 effort: Option<EffortFilter>,
1560
1561 #[arg(long)]
1564 score: bool,
1565
1566 #[arg(long, value_name = "N")]
1575 min_score: Option<f64>,
1576
1577 #[arg(long, value_name = "LEVEL", value_enum)]
1583 min_severity: Option<HealthSeverityCli>,
1584
1585 #[arg(long)]
1589 report_only: bool,
1590
1591 #[arg(long, value_name = "DURATION")]
1594 since: Option<String>,
1595
1596 #[arg(long, value_name = "N")]
1598 min_commits: Option<u32>,
1599
1600 #[expect(
1605 clippy::option_option,
1606 reason = "clap pattern: None=not passed, Some(None)=flag only, Some(Some(path))=with value"
1607 )]
1608 #[arg(long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
1609 save_snapshot: Option<Option<String>>,
1610
1611 #[arg(long)]
1615 trend: bool,
1616
1617 #[arg(long, value_name = "PATH")]
1622 trend_from: Option<PathBuf>,
1623
1624 #[arg(long, value_name = "PATH")]
1637 coverage: Option<PathBuf>,
1638
1639 #[arg(long, value_name = "PATH")]
1645 coverage_root: Option<PathBuf>,
1646
1647 #[arg(long, value_name = "PATH")]
1653 runtime_coverage: Option<PathBuf>,
1654
1655 #[arg(long, default_value_t = 100)]
1657 min_invocations_hot: u64,
1658
1659 #[arg(long, value_name = "N")]
1665 min_observation_volume: Option<u32>,
1666
1667 #[arg(long, value_name = "RATIO")]
1672 low_traffic_threshold: Option<f64>,
1673
1674 #[arg(value_name = "PATH")]
1677 path: Option<PathBuf>,
1678 },
1679
1680 Flags {
1687 #[arg(long)]
1689 top: Option<usize>,
1690
1691 #[arg(long)]
1694 retirement: bool,
1695
1696 #[arg(long = "reason", value_name = "CODE", requires = "retirement")]
1698 reasons: Vec<flags::RetirementReasonArg>,
1699
1700 #[arg(
1702 long,
1703 value_name = "KEY",
1704 requires = "retirement",
1705 default_value = "age"
1706 )]
1707 sort: flags::RetirementSortArg,
1708
1709 #[arg(
1712 long,
1713 value_name = "MODE",
1714 requires = "retirement",
1715 default_value = "blame"
1716 )]
1717 flag_age: flags::FlagAgeArg,
1718
1719 #[arg(long, value_name = "DAYS", requires = "retirement")]
1721 min_age: Option<u64>,
1722
1723 #[arg(long, value_name = "FILE", requires = "retirement")]
1727 flag_state: Option<std::path::PathBuf>,
1728
1729 #[arg(long, value_name = "DAYS", requires = "retirement")]
1732 max_flag_age: Option<u64>,
1733 },
1734
1735 Suppressions {
1745 #[arg(long, value_name = "PATH")]
1747 file: Vec<std::path::PathBuf>,
1748 },
1749
1750 Explain {
1756 #[arg(required = true, num_args = 1.., value_name = "ISSUE_TYPE")]
1758 issue_type: Vec<String>,
1759 },
1760
1761 #[command(visible_alias = "review")]
1786 Audit {
1787 #[arg(long = "production-dead-code")]
1789 production_dead_code: bool,
1790
1791 #[arg(long = "production-health")]
1793 production_health: bool,
1794
1795 #[arg(long = "production-dupes")]
1797 production_dupes: bool,
1798
1799 #[arg(long)]
1802 dead_code_baseline: Option<PathBuf>,
1803
1804 #[arg(long)]
1807 health_baseline: Option<PathBuf>,
1808
1809 #[arg(long)]
1812 dupes_baseline: Option<PathBuf>,
1813
1814 #[arg(long)]
1818 max_crap: Option<f64>,
1819
1820 #[arg(long, value_name = "PATH")]
1825 coverage: Option<PathBuf>,
1826
1827 #[arg(long, value_name = "PATH")]
1831 coverage_root: Option<PathBuf>,
1832
1833 #[arg(long = "no-css")]
1835 no_css: bool,
1836
1837 #[arg(long)]
1841 css_deep: bool,
1842
1843 #[arg(long = "no-css-deep")]
1845 no_css_deep: bool,
1846
1847 #[arg(long, value_enum)]
1853 gate: Option<AuditGateArg>,
1854
1855 #[arg(long, value_name = "PATH")]
1864 runtime_coverage: Option<PathBuf>,
1865
1866 #[arg(long, default_value_t = 100)]
1869 min_invocations_hot: u64,
1870
1871 #[arg(long, value_name = "MARKER", hide = true)]
1876 gate_marker: Option<String>,
1877
1878 #[arg(long)]
1884 brief: bool,
1885
1886 #[arg(
1891 long,
1892 value_name = "N",
1893 default_value_t = audit_decision_surface::DEFAULT_DECISION_CAP
1894 )]
1895 max_decisions: usize,
1896
1897 #[arg(long, conflicts_with_all = ["walkthrough_file", "walkthrough"])]
1905 walkthrough_guide: bool,
1906
1907 #[arg(long, value_name = "PATH")]
1917 walkthrough_file: Option<PathBuf>,
1918
1919 #[arg(long, conflicts_with_all = ["walkthrough_guide", "walkthrough_file"])]
1925 walkthrough: bool,
1926
1927 #[arg(long, value_name = "PATH")]
1933 mark_viewed: Vec<PathBuf>,
1934
1935 #[arg(long)]
1939 show_cleared: bool,
1940
1941 #[arg(long)]
1947 show_deprioritized: bool,
1948
1949 #[arg(value_name = "PATH")]
1952 path: Option<PathBuf>,
1953 },
1954
1955 AuditCache {
1957 #[command(subcommand)]
1958 subcommand: AuditCacheCli,
1959 },
1960
1961 DecisionSurface {
1976 #[arg(
1979 long,
1980 value_name = "N",
1981 default_value_t = audit_decision_surface::DEFAULT_DECISION_CAP
1982 )]
1983 max_decisions: usize,
1984 },
1985
1986 Impact {
1996 #[command(subcommand)]
1997 subcommand: Option<ImpactCli>,
1998 #[arg(long)]
2002 all: bool,
2003 #[arg(long, value_enum, default_value_t = ImpactSortCli::Recent)]
2005 sort: ImpactSortCli,
2006 #[arg(long)]
2009 limit: Option<usize>,
2010 },
2011
2012 Security {
2043 #[command(subcommand)]
2044 subcommand: Option<SecuritySubcommand>,
2045 #[arg(long, value_name = "PATH")]
2052 runtime_coverage: Option<PathBuf>,
2053 #[arg(long, default_value_t = 100)]
2056 min_invocations_hot: u64,
2057 #[arg(long, value_name = "PATH")]
2061 file: Vec<std::path::PathBuf>,
2062 #[arg(long, value_name = "MODE")]
2068 gate: Option<security::SecurityGateArg>,
2069 #[arg(long)]
2071 surface: bool,
2072
2073 #[arg(value_name = "PATH")]
2076 path: Option<PathBuf>,
2077 },
2078
2079 Report {
2084 #[arg(long, value_name = "PATH")]
2087 from: PathBuf,
2088 },
2089 Schema,
2091
2092 CiTemplate {
2099 #[command(subcommand)]
2100 subcommand: CiTemplateCli,
2101 },
2102
2103 Migrate {
2105 #[arg(long, conflicts_with = "jsonc")]
2107 toml: bool,
2108
2109 #[arg(long)]
2117 jsonc: bool,
2118
2119 #[arg(long)]
2121 dry_run: bool,
2122
2123 #[arg(long, value_name = "PATH")]
2125 from: Option<PathBuf>,
2126 },
2127
2128 License {
2135 #[command(subcommand)]
2136 subcommand: LicenseCli,
2137 },
2138
2139 Telemetry {
2147 #[command(subcommand)]
2148 subcommand: TelemetryCli,
2149 },
2150
2151 Coverage {
2156 #[command(subcommand)]
2157 subcommand: CoverageCli,
2158 },
2159
2160 SetupHooks {
2173 #[arg(long, value_enum)]
2175 agent: Option<setup_hooks::HookAgentArg>,
2176
2177 #[arg(long)]
2179 dry_run: bool,
2180
2181 #[arg(long)]
2184 force: bool,
2185
2186 #[arg(long)]
2188 user: bool,
2189
2190 #[arg(long)]
2192 gitignore_claude: bool,
2193
2194 #[arg(long)]
2198 uninstall: bool,
2199 },
2200
2201 Viz {
2203 #[arg(long = "out", value_name = "PATH")]
2205 output: Option<PathBuf>,
2206
2207 #[arg(long)]
2209 no_open: bool,
2210
2211 #[arg(long = "viz-format", default_value = "html")]
2213 viz_format: viz::VizFormat,
2214 },
2215}
2216
2217#[derive(Subcommand)]
2218enum SecuritySubcommand {
2219 Survivors {
2221 #[arg(long, value_name = "PATH")]
2223 candidates: PathBuf,
2224 #[arg(long, value_name = "PATH")]
2226 verdicts: PathBuf,
2227 #[arg(long)]
2229 require_verdict_for_each_candidate: bool,
2230 },
2231 #[command(name = "blind-spots")]
2233 BlindSpots {
2234 #[arg(long, value_name = "PATH")]
2236 file: Vec<PathBuf>,
2237 },
2238}
2239
2240#[derive(clap::Subcommand)]
2241enum AuditCacheCli {
2242 Remove {
2248 #[arg(long)]
2250 dry_run: bool,
2251
2252 #[arg(long, alias = "force")]
2254 yes: bool,
2255 },
2256
2257 Prune {
2270 #[arg(long)]
2272 dry_run: bool,
2273
2274 #[arg(long, value_name = "N")]
2281 max_age_days: Option<u32>,
2282 },
2283}
2284
2285#[derive(clap::Subcommand)]
2286enum LicenseCli {
2287 Activate {
2292 #[arg(value_name = "JWT")]
2294 jwt: Option<String>,
2295
2296 #[arg(long, value_name = "PATH")]
2298 from_file: Option<PathBuf>,
2299
2300 #[arg(long, conflicts_with_all = ["jwt", "from_file"])]
2302 stdin: bool,
2303
2304 #[arg(long, requires = "email")]
2311 trial: bool,
2312
2313 #[arg(long, value_name = "ADDR")]
2315 email: Option<String>,
2316 },
2317 Status,
2319 Refresh {
2325 #[arg(long, value_name = "KEY")]
2335 api_key: Option<String>,
2336 },
2337 Deactivate,
2339}
2340
2341#[derive(Clone, Copy, clap::Subcommand)]
2342enum TelemetryCli {
2343 Status,
2345 Enable,
2347 Disable,
2349 Inspect {
2351 #[arg(long)]
2353 example: bool,
2354 },
2355}
2356
2357#[derive(clap::Subcommand)]
2358enum CiTemplateCli {
2359 Gitlab {
2361 #[arg(long, value_name = "DIR", num_args = 0..=1, default_missing_value = ".")]
2365 vendor: Option<PathBuf>,
2366
2367 #[arg(long)]
2369 force: bool,
2370 },
2371}
2372
2373#[derive(clap::Subcommand)]
2374enum CoverageCli {
2375 Setup {
2377 #[arg(short = 'y', long)]
2379 yes: bool,
2380
2381 #[arg(long)]
2383 non_interactive: bool,
2384
2385 #[arg(long)]
2387 json: bool,
2388 },
2389 Analyze {
2395 #[arg(long, value_name = "PATH", conflicts_with = "cloud")]
2399 runtime_coverage: Option<PathBuf>,
2400
2401 #[arg(long, visible_alias = "runtime-coverage-cloud")]
2403 cloud: bool,
2404
2405 #[arg(long, value_name = "KEY")]
2407 api_key: Option<String>,
2408
2409 #[arg(long, value_name = "URL")]
2411 api_endpoint: Option<String>,
2412
2413 #[arg(long, value_name = "OWNER/REPO")]
2419 repo: Option<String>,
2420
2421 #[arg(long, value_name = "ID")]
2423 project_id: Option<String>,
2424
2425 #[arg(long, value_name = "DAYS", default_value_t = 30)]
2427 coverage_period: u16,
2428
2429 #[arg(long, value_name = "ENV")]
2431 environment: Option<String>,
2432
2433 #[arg(long, value_name = "SHA")]
2435 commit_sha: Option<String>,
2436
2437 #[arg(long)]
2439 production: bool,
2440
2441 #[arg(long, default_value_t = 100)]
2443 min_invocations_hot: u64,
2444
2445 #[arg(long, value_name = "N")]
2447 min_observation_volume: Option<u32>,
2448
2449 #[arg(long, value_name = "RATIO")]
2451 low_traffic_threshold: Option<f64>,
2452
2453 #[arg(long)]
2455 top: Option<usize>,
2456
2457 #[arg(long)]
2459 blast_radius: bool,
2460
2461 #[arg(long)]
2463 importance: bool,
2464
2465 #[arg(long)]
2467 debug_unmatched: bool,
2468 },
2469 UploadInventory {
2480 #[arg(long, value_name = "KEY")]
2489 api_key: Option<String>,
2490
2491 #[arg(long, value_name = "URL")]
2496 api_endpoint: Option<String>,
2497
2498 #[arg(long, value_name = "PROJECT_ID")]
2503 project_id: Option<String>,
2504
2505 #[arg(long, value_name = "SHA")]
2510 git_sha: Option<String>,
2511
2512 #[arg(long)]
2518 allow_dirty: bool,
2519
2520 #[arg(long, value_name = "GLOB", num_args = 0..)]
2524 exclude_paths: Vec<String>,
2525
2526 #[arg(long, value_name = "PREFIX")]
2539 path_prefix: Option<String>,
2540
2541 #[arg(long)]
2543 dry_run: bool,
2544
2545 #[arg(long)]
2551 with_callers: bool,
2552
2553 #[arg(long)]
2557 ignore_upload_errors: bool,
2558 },
2559 UploadSourceMaps {
2572 #[arg(long, value_name = "PATH", default_value = "dist")]
2574 dir: PathBuf,
2575
2576 #[arg(long, value_name = "GLOB", default_value = "**/*.map")]
2578 include: String,
2579
2580 #[arg(long, value_name = "GLOB", default_value = "**/node_modules/**")]
2584 exclude: Vec<String>,
2585
2586 #[arg(long, value_name = "NAME")]
2590 repo: Option<String>,
2591
2592 #[arg(long, value_name = "SHA")]
2597 git_sha: Option<String>,
2598
2599 #[arg(long, value_name = "URL")]
2601 endpoint: Option<String>,
2602
2603 #[arg(long, value_name = "BOOL", default_value_t = true, action = clap::ArgAction::Set)]
2608 strip_path: bool,
2609
2610 #[arg(long)]
2612 dry_run: bool,
2613
2614 #[arg(long, value_name = "N", default_value_t = 4)]
2616 concurrency: usize,
2617
2618 #[arg(long)]
2620 fail_fast: bool,
2621 },
2622 UploadStaticFindings {
2629 #[arg(long, value_name = "KEY")]
2639 api_key: Option<String>,
2640
2641 #[arg(long, value_name = "URL")]
2646 api_endpoint: Option<String>,
2647
2648 #[arg(long, value_name = "PROJECT_ID")]
2653 project_id: Option<String>,
2654
2655 #[arg(long, value_name = "SHA")]
2660 git_sha: Option<String>,
2661
2662 #[arg(long)]
2668 allow_dirty: bool,
2669
2670 #[arg(long)]
2672 dry_run: bool,
2673
2674 #[arg(long)]
2678 ignore_upload_errors: bool,
2679 },
2680 ReviewPacket {
2691 #[arg(long, value_name = "KEY")]
2693 api_key: Option<String>,
2694
2695 #[arg(long, value_name = "URL")]
2697 api_endpoint: Option<String>,
2698
2699 #[arg(long, value_name = "OWNER/REPO")]
2704 repo: Option<String>,
2705
2706 #[arg(long, value_name = "ID")]
2708 project_id: Option<String>,
2709
2710 #[arg(long, value_name = "DAYS")]
2712 coverage_period: Option<u16>,
2713
2714 #[arg(long, value_name = "SHA")]
2716 commit_sha: Option<String>,
2717
2718 #[arg(long = "file", value_name = "PATH")]
2720 files: Vec<String>,
2721
2722 #[arg(long = "function", value_name = "FILE:NAME[:LINE]")]
2724 functions: Vec<String>,
2725 },
2726 DeploymentChanges {
2734 #[arg(long, value_name = "KEY")]
2736 api_key: Option<String>,
2737
2738 #[arg(long, value_name = "URL")]
2740 api_endpoint: Option<String>,
2741
2742 #[arg(long, value_name = "OWNER/REPO")]
2747 repo: Option<String>,
2748
2749 #[arg(long, value_name = "SHA")]
2751 sha: Option<String>,
2752
2753 #[arg(long, value_name = "KIND", value_parser = [
2755 "stopped", "new_not_called", "heated_up", "cooled_down", "new_called", "unchanged",
2756 ])]
2757 change: Option<String>,
2758
2759 #[arg(long, value_name = "N")]
2761 limit: Option<u16>,
2762
2763 #[arg(long, value_name = "CURSOR")]
2765 cursor: Option<String>,
2766 },
2767}
2768
2769#[derive(Subcommand)]
2770enum CiCli {
2771 PlanPrComment {
2773 #[arg(long)]
2775 body: PathBuf,
2776
2777 #[arg(long)]
2779 marker_id: String,
2780
2781 #[arg(long)]
2783 clean: bool,
2784
2785 #[arg(long)]
2787 existing_comment_id: Option<String>,
2788
2789 #[arg(long)]
2791 existing_body: Option<PathBuf>,
2792 },
2793
2794 PostPrComment {
2796 #[arg(long, value_enum)]
2798 provider: CiProviderArg,
2799
2800 #[arg(long)]
2802 pr: Option<String>,
2803
2804 #[arg(long)]
2806 mr: Option<String>,
2807
2808 #[arg(long)]
2810 body: PathBuf,
2811
2812 #[arg(long)]
2814 envelope: Option<PathBuf>,
2815
2816 #[arg(long)]
2818 marker_id: String,
2819
2820 #[arg(long)]
2822 clean: bool,
2823
2824 #[arg(long)]
2826 repo: Option<String>,
2827
2828 #[arg(long = "project-id")]
2830 project_id: Option<String>,
2831
2832 #[arg(long = "api-url")]
2834 api_url: Option<String>,
2835
2836 #[arg(long)]
2838 dry_run: bool,
2839 },
2840
2841 PostReview {
2843 #[arg(long, value_enum)]
2845 provider: CiProviderArg,
2846
2847 #[arg(long)]
2849 pr: Option<String>,
2850
2851 #[arg(long)]
2853 mr: Option<String>,
2854
2855 #[arg(long)]
2857 envelope: PathBuf,
2858
2859 #[arg(long)]
2861 repo: Option<String>,
2862
2863 #[arg(long = "project-id")]
2865 project_id: Option<String>,
2866
2867 #[arg(long = "api-url")]
2869 api_url: Option<String>,
2870
2871 #[arg(long)]
2873 dry_run: bool,
2874 },
2875
2876 PostCheckRun {
2878 #[arg(long, value_enum)]
2880 provider: CiProviderArg,
2881
2882 #[arg(long)]
2884 decision: PathBuf,
2885
2886 #[arg(long)]
2888 repo: String,
2889
2890 #[arg(long = "head-sha")]
2892 head_sha: String,
2893
2894 #[arg(long = "api-url")]
2896 api_url: Option<String>,
2897
2898 #[arg(long = "split-gates")]
2900 split_gates: bool,
2901
2902 #[arg(long)]
2904 dry_run: bool,
2905 },
2906
2907 ReconcileReview {
2909 #[arg(long, value_enum)]
2911 provider: CiProviderArg,
2912
2913 #[arg(long)]
2915 pr: Option<String>,
2916
2917 #[arg(long)]
2919 mr: Option<String>,
2920
2921 #[arg(long)]
2923 envelope: PathBuf,
2924
2925 #[arg(long)]
2927 repo: Option<String>,
2928
2929 #[arg(long = "project-id")]
2931 project_id: Option<String>,
2932
2933 #[arg(long = "api-url")]
2935 api_url: Option<String>,
2936
2937 #[arg(long)]
2939 dry_run: bool,
2940 },
2941}
2942
2943#[derive(Subcommand)]
2944enum RulePackCli {
2945 Init {
2947 name: Option<String>,
2949
2950 #[arg(long, default_value = "starter")]
2952 template: String,
2953
2954 #[arg(long, default_value = "rule-packs")]
2956 dir: String,
2957
2958 #[arg(long)]
2960 no_config: bool,
2961 },
2962
2963 List,
2965
2966 Test {
2968 pack: Option<PathBuf>,
2970 },
2971
2972 Schema,
2974}
2975
2976#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
2978pub enum BaselineModeArg {
2979 #[default]
2981 Count,
2982 Identity,
2985}
2986
2987impl From<BaselineModeArg> for fallow_engine::baseline::HealthBaselineMode {
2988 fn from(value: BaselineModeArg) -> Self {
2989 match value {
2990 BaselineModeArg::Count => Self::Count,
2991 BaselineModeArg::Identity => Self::Identity,
2992 }
2993 }
2994}
2995
2996#[derive(Clone, Copy, Debug, clap::ValueEnum)]
2997enum CiProviderArg {
2998 Github,
2999 Gitlab,
3000}
3001
3002#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
3004enum TypeAwareRequireArg {
3005 BestEffort,
3007 Complete,
3009}
3010
3011impl From<TypeAwareRequireArg> for fallow_config::TypeAwareRequire {
3012 fn from(value: TypeAwareRequireArg) -> Self {
3013 match value {
3014 TypeAwareRequireArg::BestEffort => Self::BestEffort,
3015 TypeAwareRequireArg::Complete => Self::Complete,
3016 }
3017 }
3018}
3019
3020#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
3022pub enum EffortFilter {
3023 Low,
3024 Medium,
3025 High,
3026}
3027
3028impl EffortFilter {
3029 const fn to_estimate(self) -> fallow_output::EffortEstimate {
3031 match self {
3032 Self::Low => fallow_output::EffortEstimate::Low,
3033 Self::Medium => fallow_output::EffortEstimate::Medium,
3034 Self::High => fallow_output::EffortEstimate::High,
3035 }
3036 }
3037}
3038
3039#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
3041pub enum HealthSeverityCli {
3042 Moderate,
3043 High,
3044 Critical,
3045}
3046
3047impl HealthSeverityCli {
3048 const fn to_health_severity(self) -> fallow_output::FindingSeverity {
3050 match self {
3051 Self::Moderate => fallow_output::FindingSeverity::Moderate,
3052 Self::High => fallow_output::FindingSeverity::High,
3053 Self::Critical => fallow_output::FindingSeverity::Critical,
3054 }
3055 }
3056}
3057
3058#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
3064pub enum EmailModeArg {
3065 Raw,
3067 Handle,
3069 Anonymized,
3071 #[value(hide = true)]
3073 Hash,
3074}
3075
3076impl EmailModeArg {
3077 const fn to_config(self) -> fallow_config::EmailMode {
3079 match self {
3080 Self::Raw => fallow_config::EmailMode::Raw,
3081 Self::Handle => fallow_config::EmailMode::Handle,
3082 Self::Anonymized => fallow_config::EmailMode::Anonymized,
3083 Self::Hash => fallow_config::EmailMode::Hash,
3084 }
3085 }
3086}
3087
3088#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
3090pub enum AuditGateArg {
3091 NewOnly,
3093 All,
3095}
3096
3097impl From<AuditGateArg> for fallow_config::AuditGate {
3098 fn from(value: AuditGateArg) -> Self {
3099 match value {
3100 AuditGateArg::NewOnly => Self::NewOnly,
3101 AuditGateArg::All => Self::All,
3102 }
3103 }
3104}
3105
3106fn parse_min_occurrences(s: &str) -> Result<usize, String> {
3110 let value: usize = s
3111 .parse()
3112 .map_err(|_| format!("`{s}` is not a non-negative integer"))?;
3113 if value < 2 {
3114 return Err(format!(
3115 "must be at least 2 (got {value}); a single occurrence isn't a duplicate"
3116 ));
3117 }
3118 Ok(value)
3119}
3120
3121fn resolve_audit_baseline_path(
3127 root: &std::path::Path,
3128 cli: Option<&std::path::Path>,
3129 config: Option<&str>,
3130) -> Option<PathBuf> {
3131 let path = cli.map(std::path::Path::to_path_buf).or_else(|| {
3132 config.map(|p| {
3133 let path = PathBuf::from(p);
3134 if path_util::is_absolute_path_any_platform(&path) {
3135 path
3136 } else {
3137 root.join(path)
3138 }
3139 })
3140 })?;
3141 if path_util::is_absolute_path_any_platform(&path) {
3142 Some(path)
3143 } else {
3144 Some(root.join(path))
3145 }
3146}
3147
3148fn emit_known_failure(
3149 message: &str,
3150 exit_code: u8,
3151 output: fallow_config::OutputFormat,
3152 reason: telemetry::FailureReason,
3153) -> ExitCode {
3154 telemetry::note_failure_reason(reason);
3155 emit_error(message, exit_code, output)
3156}
3157
3158fn emit_known_failure_with_style(
3159 message: &str,
3160 exit_code: u8,
3161 output: fallow_config::OutputFormat,
3162 json_style: json_style::JsonStyle,
3163 reason: telemetry::FailureReason,
3164) -> ExitCode {
3165 telemetry::note_failure_reason(reason);
3166 error::emit_error_with_style(message, exit_code, output, json_style)
3167}
3168
3169fn unsupported_security_global(cli: &Cli) -> Option<&'static str> {
3170 if cli.baseline.is_some() {
3171 Some("--baseline")
3172 } else if cli.save_baseline.is_some() {
3173 Some("--save-baseline")
3174 } else if cli.fail_on_stale_baseline {
3175 Some("--fail-on-stale-baseline")
3176 } else if cli.fail_on_baseline_growth {
3177 Some("--fail-on-baseline-growth")
3178 } else if cli.baseline_base.is_some() {
3179 Some("--baseline-base")
3180 } else if cli.fail_on_parse_error {
3181 Some("--fail-on-parse-error")
3182 } else if cli.production {
3183 Some("--production")
3184 } else if cli.no_production {
3185 Some("--no-production")
3186 } else if cli.no_package_baselines {
3187 Some("--no-package-baselines")
3188 } else if cli.group_by.is_some() {
3189 Some("--group-by")
3190 } else if cli.performance {
3191 Some("--performance")
3192 } else if cli.explain_skipped {
3193 Some("--explain-skipped")
3194 } else if cli.fail_on_regression {
3195 Some("--fail-on-regression")
3196 } else if cli.regression_baseline.is_some() {
3197 Some("--regression-baseline")
3198 } else if cli.save_regression_baseline.is_some() {
3199 Some("--save-regression-baseline")
3200 } else if let Some(flag) = cli.dupes_overrides().first_global_flag() {
3201 Some(flag)
3202 } else if cli.include_entry_exports {
3203 Some("--include-entry-exports")
3204 } else {
3205 None
3206 }
3207}
3208
3209struct DispatchContext<'a> {
3210 cli: &'a Cli,
3211 root: &'a std::path::Path,
3212 output: fallow_config::OutputFormat,
3213 quiet: bool,
3214 fail_on_issues: bool,
3215 json_style: json_style::JsonStyle,
3216 threads: usize,
3217 tolerance: regression::Tolerance,
3218 save_regression_file: Option<&'a std::path::PathBuf>,
3219 save_to_config: bool,
3220}
3221
3222impl DispatchContext<'_> {
3223 fn production_modes(
3224 &self,
3225 dead_code: bool,
3226 health: bool,
3227 dupes: bool,
3228 ) -> Result<ProductionModes, ExitCode> {
3229 resolve_production_modes(self.cli, self.root, self.output, dead_code, health, dupes)
3230 }
3231
3232 fn production_for(
3233 &self,
3234 analysis: fallow_config::ProductionAnalysis,
3235 ) -> Result<bool, ExitCode> {
3236 self.production_modes(false, false, false)
3237 .map(|modes| modes.for_analysis(analysis))
3238 }
3239
3240 fn growth_flags(&self) -> baseline_growth::GrowthFlags<'_> {
3241 baseline_growth::GrowthFlags {
3242 enabled: self.cli.fail_on_baseline_growth,
3243 base: self.cli.baseline_base.as_deref(),
3244 changed_since: self.cli.changed_since.as_deref(),
3245 }
3246 }
3247
3248 fn growth_ctx(
3249 &self,
3250 owner: baseline_growth::GrowthOwner,
3251 ) -> baseline_growth::GrowthContext<'_> {
3252 baseline_growth::GrowthContext {
3253 root: self.root,
3254 flags: self.growth_flags(),
3255 owner,
3256 output: self.output,
3257 json_style: self.json_style,
3258 }
3259 }
3260
3261 fn regression_opts(&self, scoped: bool) -> regression::RegressionOpts<'_> {
3262 regression::RegressionOpts {
3263 fail_on_regression: self.cli.fail_on_regression,
3264 tolerance: self.tolerance,
3265 regression_baseline_file: self.cli.regression_baseline.as_deref(),
3266 save_target: if let Some(path) = self.save_regression_file {
3267 regression::SaveRegressionTarget::File(path)
3268 } else if self.save_to_config {
3269 regression::SaveRegressionTarget::Config
3270 } else {
3271 regression::SaveRegressionTarget::None
3272 },
3273 scoped,
3274 quiet: self.quiet,
3275 output: self.output,
3276 }
3277 }
3278}
3279
3280#[cfg(unix)]
3295fn signal_test_helper() -> ExitCode {
3296 use std::io::Write as _;
3297 use std::process::Command;
3298
3299 if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER_GRACEFUL").is_some() {
3300 signal::set_graceful_mode();
3301 }
3302
3303 let mut command = Command::new("sleep");
3304 command.arg("30");
3305 let child = match signal::ScopedChild::spawn(&mut command) {
3306 Ok(c) => c,
3307 Err(err) => {
3308 let _ = writeln!(std::io::stderr(), "spawn sleep failed: {err}");
3309 return ExitCode::from(2);
3310 }
3311 };
3312 let pid = child.id();
3313 let stdout = std::io::stdout();
3314 let mut lock = stdout.lock();
3315 let _ = writeln!(lock, "{pid}");
3316 let _ = lock.flush();
3317 drop(lock);
3318 let _ = child.wait_with_output();
3319 if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER_GRACEFUL").is_some() {
3320 return ExitCode::SUCCESS;
3321 }
3322 std::thread::sleep(std::time::Duration::from_secs(5));
3323 ExitCode::SUCCESS
3324}
3325
3326#[cfg(not(unix))]
3327fn signal_test_helper() -> ExitCode {
3328 ExitCode::from(2)
3329}
3330
3331fn install_spawn_hooks() {
3332 fallow_engine::churn::set_spawn_hook(signal::scoped_child::output);
3333 fallow_engine::changed_files::set_spawn_hook(signal::scoped_child::output);
3334}
3335
3336fn install_signal_handlers() {
3337 if let Err(err) = signal::install_handlers() {
3338 use std::io::Write as _;
3339 let stderr = std::io::stderr();
3340 let mut lock = stderr.lock();
3341 let _ = writeln!(lock, "fallow: failed to install signal handlers: {err}");
3342 }
3343}
3344
3345fn redirect_report_to_file(
3350 path: &std::path::Path,
3351 output: fallow_config::OutputFormat,
3352) -> Result<(), ExitCode> {
3353 match fallow_engine::write_guard::create_file(
3354 path,
3355 fallow_engine::write_guard::WriteTarget::Path,
3356 ) {
3357 Ok(file) => {
3358 report::sink::set_file_sink(file);
3359 colored::control::set_override(false);
3360 Ok(())
3361 }
3362 Err(e) if e.is_directory() => Err(emit_error(
3363 &format!(
3364 "failed to create the directory of {} for --output-file: {e}",
3365 path.display()
3366 ),
3367 2,
3368 output,
3369 )),
3370 Err(e) => Err(emit_error(
3371 &format!("failed to open {} for --output-file: {e}", path.display()),
3372 2,
3373 output,
3374 )),
3375 }
3376}
3377
3378fn finalize_report_file(
3381 path: &std::path::Path,
3382 quiet: bool,
3383 output: fallow_config::OutputFormat,
3384) -> Result<(), ExitCode> {
3385 if let Err(e) = report::sink::flush() {
3386 return Err(emit_error(
3387 &format!("failed to write {}: {e}", path.display()),
3388 2,
3389 output,
3390 ));
3391 }
3392 if !quiet && report::sink::wrote() {
3396 eprintln!("Report written to {}", path.display());
3397 }
3398 Ok(())
3399}
3400
3401pub fn run() -> ExitCode {
3406 process_clock::mark_process_start();
3407 install_signal_handlers();
3408 install_spawn_hooks();
3409
3410 if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER").is_some() {
3411 return signal_test_helper();
3412 }
3413
3414 let (mut cli, fmt) = match parse_cli_args() {
3415 Ok(parsed) => parsed,
3416 Err(code) => return code,
3417 };
3418 if cli.pretty && !fmt.payload_is_json {
3419 eprintln!(
3420 "Error: --pretty requires JSON output. Use --format json --pretty, or remove --pretty."
3421 );
3422 return ExitCode::from(2);
3423 }
3424
3425 if let Some(code) = reject_global_baseline_flags(&cli, &fmt) {
3426 return code;
3427 }
3428
3429 if let Some(code) = run_schema_command_if_requested(&cli, fmt.json_style) {
3430 return code;
3431 }
3432
3433 if let Some(code) = run_telemetry_command_if_requested(&mut cli, fmt.output, fmt.json_style) {
3434 return code;
3435 }
3436 if let Some(code) = run_doctor_command_if_requested(&cli, &fmt) {
3437 return code;
3438 }
3439 if is_impact_statusline(&cli) {
3440 let (root, _) = match validate_inputs(&cli, fmt.output, fmt.json_style) {
3441 Ok(validated) => validated,
3442 Err(code) => return code,
3443 };
3444 return cli_impact::render_impact_statusline(&root);
3445 }
3446 let telemetry_run = start_telemetry_run(&cli, &fmt);
3447
3448 let (root, threads) = match validate_inputs(&cli, fmt.output, fmt.json_style) {
3449 Ok(v) => v,
3450 Err(code) => {
3451 return record_run_epilogue(telemetry_run, code, None, cli.parent_run.as_deref(), None);
3452 }
3453 };
3454
3455 let FormatConfig {
3456 output,
3457 payload_is_json: _,
3458 quiet,
3459 fail_on_issues,
3460 json_style,
3461 } = fmt;
3462
3463 let tolerance =
3464 match run_pre_dispatch_checks(&cli, &root, output, json_style, quiet, telemetry_run) {
3465 Ok(tolerance) => tolerance,
3466 Err(code) => return code,
3467 };
3468
3469 if let Some(note) = write_scope::default_cache_dir_note(&cli, &root) {
3470 eprintln!("{note}");
3471 cli.no_cache = true;
3472 }
3473
3474 let (save_regression_file, save_to_config) = regression_save_targets(&cli);
3475
3476 let hint_root = (!matches!(
3480 cli.command,
3481 Some(Command::Agent { .. } | Command::Hooks { .. } | Command::SetupHooks { .. })
3482 ))
3483 .then_some(root.as_path());
3484 let command = cli.command.take();
3485 process_clock::record_startup();
3486 let dispatch = DispatchContext {
3487 cli: &cli,
3488 root: &root,
3489 output,
3490 quiet,
3491 fail_on_issues,
3492 json_style,
3493 threads,
3494 tolerance,
3495 save_regression_file: save_regression_file.as_ref(),
3496 save_to_config,
3497 };
3498 let exit_code = match dispatch_and_finalize(&dispatch, command) {
3499 Ok(code) => code,
3500 Err(code) => return code,
3501 };
3502 record_run_epilogue(
3503 telemetry_run,
3504 exit_code,
3505 None,
3506 cli.parent_run.as_deref(),
3507 hint_root,
3508 )
3509}
3510
3511#[doc(hidden)]
3515pub fn benchmark_fix_dry_run(root: &Path, threads: usize) -> (ExitCode, usize) {
3516 let config_path = None;
3517 fix::run_fix_with_count(&fix::FixOptions {
3518 root,
3519 config_path: &config_path,
3520 output: fallow_config::OutputFormat::Json,
3521 json_style: json_style::JsonStyle::Compact,
3522 no_cache: true,
3523 threads,
3524 quiet: true,
3525 emit_output: false,
3526 allow_remote_extends: false,
3527 dry_run: true,
3528 yes: false,
3529 production: false,
3530 no_create_config: true,
3531 type_aware: None,
3532 type_aware_projects: &[],
3533 type_aware_require: None,
3534 scope: None,
3535 })
3536}
3537
3538#[doc(hidden)]
3541pub use audit::AuditReviewBenchmarkCorpus;
3542
3543#[doc(hidden)]
3546pub fn create_audit_review_benchmark_corpus(
3547 root: &Path,
3548 changed_files: &[PathBuf],
3549 threads: usize,
3550) -> Result<AuditReviewBenchmarkCorpus, ExitCode> {
3551 audit::create_audit_review_benchmark_corpus(root, changed_files, threads)
3552}
3553
3554#[doc(hidden)]
3557pub fn benchmark_audit_review_brief_many_changed_files_json(
3558 corpus: &mut AuditReviewBenchmarkCorpus,
3559) -> (ExitCode, usize, usize, usize, usize, usize) {
3560 match audit::benchmark_audit_review_brief_many_changed_files_json(corpus) {
3561 Ok(result) => (
3562 ExitCode::SUCCESS,
3563 result.introduced_count,
3564 result.inherited_count,
3565 result.public_api_added_count,
3566 result.decision_count,
3567 result.rendered_bytes,
3568 ),
3569 Err(code) => (code, 0, 0, 0, 0, 0),
3570 }
3571}
3572
3573#[doc(hidden)]
3574pub use inspect::InspectBenchmarkCorpus;
3575
3576#[doc(hidden)]
3579pub fn create_inspect_benchmark_corpus(root: &Path, threads: usize) -> InspectBenchmarkCorpus {
3580 inspect::create_inspect_benchmark_corpus(root, threads)
3581}
3582
3583#[doc(hidden)]
3586pub fn benchmark_inspect_file_evidence_bundle_json(
3587 root: &Path,
3588 threads: usize,
3589 corpus: &InspectBenchmarkCorpus,
3590) -> (ExitCode, usize, usize) {
3591 match inspect::benchmark_inspect_file_evidence_bundle_json(root, threads, corpus) {
3592 Ok((child_call_count, rendered_bytes)) => {
3593 (ExitCode::SUCCESS, child_call_count, rendered_bytes)
3594 }
3595 Err(_) => (ExitCode::from(2), 0, 0),
3596 }
3597}
3598
3599#[doc(hidden)]
3602pub fn benchmark_dead_code_json(root: &Path, threads: usize) -> (ExitCode, usize, usize) {
3603 match check::benchmark_dead_code_json(root, threads) {
3604 Ok((issue_count, rendered_bytes)) => (ExitCode::SUCCESS, issue_count, rendered_bytes),
3605 Err(code) => (code, 0, 0),
3606 }
3607}
3608
3609#[doc(hidden)]
3612pub fn benchmark_security_json(root: &Path, threads: usize) -> (ExitCode, usize, usize) {
3613 match security::benchmark_security_json(root, threads) {
3614 Ok((finding_count, rendered_bytes)) => (ExitCode::SUCCESS, finding_count, rendered_bytes),
3615 Err(code) => (code, 0, 0),
3616 }
3617}
3618
3619#[doc(hidden)]
3620pub use security::{SecurityBlindSpotsBenchmarkResult, SecuritySurvivorsBenchmarkCorpus};
3621
3622#[doc(hidden)]
3625pub fn create_security_survivors_benchmark_corpus(
3626 root: &Path,
3627 threads: usize,
3628) -> Result<SecuritySurvivorsBenchmarkCorpus, ExitCode> {
3629 security::create_security_survivors_benchmark_corpus(root, threads)
3630}
3631
3632#[doc(hidden)]
3635pub fn benchmark_security_survivors_json(
3636 corpus: &SecuritySurvivorsBenchmarkCorpus,
3637) -> (ExitCode, usize, usize, usize, usize, usize) {
3638 match security::benchmark_security_survivors_json(corpus) {
3639 Ok((survivors, dismissed, needs_human_review, unverdicted, rendered_bytes)) => (
3640 ExitCode::SUCCESS,
3641 survivors,
3642 dismissed,
3643 needs_human_review,
3644 unverdicted,
3645 rendered_bytes,
3646 ),
3647 Err(_) => (ExitCode::from(2), 0, 0, 0, 0, 0),
3648 }
3649}
3650
3651#[doc(hidden)]
3654pub fn benchmark_security_blind_spots_json(
3655 root: &Path,
3656 diagnostics: &[fallow_types::results::SecurityUnresolvedCalleeDiagnostic],
3657) -> SecurityBlindSpotsBenchmarkResult {
3658 security::benchmark_security_blind_spots_json(root, diagnostics)
3659}
3660
3661#[doc(hidden)]
3664pub fn benchmark_list_json(root: &Path, threads: usize) -> (ExitCode, usize, usize, usize, usize) {
3665 match list::benchmark_list_json(root, threads) {
3666 Ok((file_count, entry_point_count, workspace_count, rendered_bytes)) => (
3667 ExitCode::SUCCESS,
3668 file_count,
3669 entry_point_count,
3670 workspace_count,
3671 rendered_bytes,
3672 ),
3673 Err(code) => (code, 0, 0, 0, 0),
3674 }
3675}
3676
3677#[doc(hidden)]
3680pub fn benchmark_list_boundaries_json(
3681 root: &Path,
3682 threads: usize,
3683) -> (ExitCode, usize, usize, usize, usize) {
3684 match list::benchmark_list_boundaries_json(root, threads) {
3685 Ok((zone_count, rule_count, matched_file_count, rendered_bytes)) => (
3686 ExitCode::SUCCESS,
3687 zone_count,
3688 rule_count,
3689 matched_file_count,
3690 rendered_bytes,
3691 ),
3692 Err(code) => (code, 0, 0, 0, 0),
3693 }
3694}
3695
3696#[doc(hidden)]
3699pub use watch::WatchFilterBenchmarkGlobalGitignore;
3700
3701#[doc(hidden)]
3704pub fn create_watch_filter_benchmark_global_gitignore() -> WatchFilterBenchmarkGlobalGitignore {
3705 watch::create_benchmark_global_gitignore()
3706}
3707
3708#[doc(hidden)]
3711pub fn benchmark_watch_filter_initialization(
3712 config: &fallow_config::ResolvedConfig,
3713 global_gitignore: &WatchFilterBenchmarkGlobalGitignore,
3714) -> (usize, usize) {
3715 watch::benchmark_filter_initialization(config, global_gitignore)
3716}
3717
3718#[doc(hidden)]
3721pub fn benchmark_viz_html(root: &Path, threads: usize) -> (ExitCode, usize, usize, usize) {
3722 match viz::benchmark_viz_html(root, threads) {
3723 Ok((file_count, edge_count, rendered_bytes)) => {
3724 (ExitCode::SUCCESS, file_count, edge_count, rendered_bytes)
3725 }
3726 Err(code) => (code, 0, 0, 0),
3727 }
3728}
3729
3730#[doc(hidden)]
3733pub fn benchmark_rule_pack_test_json(root: &Path, threads: usize) -> (ExitCode, usize, usize) {
3734 match rule_pack::benchmark_rule_pack_test_json(root, threads) {
3735 Ok((finding_count, rendered_bytes)) => (ExitCode::SUCCESS, finding_count, rendered_bytes),
3736 Err(code) => (code, 0, 0),
3737 }
3738}
3739
3740#[doc(hidden)]
3743pub fn benchmark_recommend_json(root: &Path) -> (ExitCode, usize, usize, bool, usize) {
3744 match onboarding::benchmark_recommend_json(root) {
3745 Ok((decision_count, framework_count, heterogeneous, rendered_bytes)) => (
3746 ExitCode::SUCCESS,
3747 decision_count,
3748 framework_count,
3749 heterogeneous,
3750 rendered_bytes,
3751 ),
3752 Err(_) => (ExitCode::from(2), 0, 0, false, 0),
3753 }
3754}
3755
3756#[doc(hidden)]
3759pub fn benchmark_runtime_coverage_analyze_json(
3760 root: &Path,
3761 runtime_coverage_path: &Path,
3762 response_bytes: &[u8],
3763 threads: usize,
3764) -> (ExitCode, usize, usize, usize, String) {
3765 match coverage::benchmark_local_json(root, runtime_coverage_path, response_bytes, threads) {
3766 Ok((finding_count, hot_path_count, request_bytes, rendered)) => (
3767 ExitCode::SUCCESS,
3768 finding_count,
3769 hot_path_count,
3770 request_bytes,
3771 rendered,
3772 ),
3773 Err(code) => (code, 0, 0, 0, String::new()),
3774 }
3775}
3776
3777fn is_impact_statusline(cli: &Cli) -> bool {
3780 matches!(
3781 cli.command.as_ref(),
3782 Some(Command::Impact {
3783 subcommand: Some(ImpactCli::Statusline),
3784 all: false,
3785 ..
3786 })
3787 )
3788}
3789
3790fn dispatch_and_finalize(
3794 dispatch: &DispatchContext<'_>,
3795 command: Option<Command>,
3796) -> Result<ExitCode, ExitCode> {
3797 let cli = dispatch.cli;
3798 let output = dispatch.output;
3799 let quiet = dispatch.quiet;
3800
3801 if let Some(path) = cli.output_file.as_deref()
3804 && let Err(code) = redirect_report_to_file(path, output)
3805 {
3806 return Err(code);
3807 }
3808
3809 let exit_code = if command.is_some() && cli_has_bare_coverage_input(cli) {
3810 emit_error(bare_coverage_subcommand_error_message(), 2, output)
3811 } else if command.is_some()
3812 && let Some(flag) = cli_bare_combined_baseline_flag(cli)
3813 {
3814 emit_error(
3815 &bare_combined_baseline_subcommand_error_message(flag),
3816 2,
3817 output,
3818 )
3819 } else {
3820 match command {
3821 None => dispatch_bare_command(dispatch),
3822 Some(cmd) => dispatch_subcommand(cmd, dispatch),
3823 }
3824 };
3825
3826 if let Some(path) = cli.output_file.as_deref()
3827 && let Err(code) = finalize_report_file(path, quiet, output)
3828 {
3829 return Err(code);
3830 }
3831 Ok(exit_code)
3832}
3833
3834fn run_telemetry_command_if_requested(
3835 cli: &mut Cli,
3836 output: fallow_config::OutputFormat,
3837 json_style: json_style::JsonStyle,
3838) -> Option<ExitCode> {
3839 if matches!(cli.command, Some(Command::Telemetry { .. }))
3840 && let Some(Command::Telemetry { subcommand }) = cli.command.take()
3841 {
3842 return Some(telemetry::run(
3843 map_telemetry_subcommand(subcommand),
3844 output,
3845 json_style,
3846 ));
3847 }
3848 None
3849}
3850
3851fn run_doctor_command_if_requested(cli: &Cli, format: &FormatConfig) -> Option<ExitCode> {
3856 if !matches!(cli.command, Some(Command::Doctor)) {
3857 return None;
3858 }
3859
3860 if let Some(flag) = unsupported_doctor_option(cli) {
3861 let message = if flag == "--output-file" {
3862 "--output-file is not valid with `fallow doctor`; doctor is read-only and writes its report to stdout".to_string()
3863 } else {
3864 format!("{flag} is not valid with `fallow doctor`.")
3865 };
3866 return Some(crate::error::emit_error_with_style(
3867 &message,
3868 2,
3869 format.output,
3870 format.json_style,
3871 ));
3872 }
3873 if let Err(code) = doctor::validate_output(format.output, format.json_style) {
3874 return Some(code);
3875 }
3876
3877 let root = cli.root.clone().unwrap_or_else(|| {
3878 std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
3879 });
3880 let config_path = cli.config.as_ref().map(|path| {
3881 if path_util::is_absolute_path_any_platform(path) {
3882 path.clone()
3883 } else {
3884 root.join(path)
3885 }
3886 });
3887 let report = doctor::collect_report(&root, config_path.as_deref());
3888 Some(doctor::render_report(
3889 &report,
3890 format.output,
3891 format.json_style,
3892 ))
3893}
3894
3895fn unsupported_doctor_option(cli: &Cli) -> Option<&'static str> {
3899 let dupes_flag = cli.dupes_overrides().first_global_flag();
3900 [
3901 (cli.allow_remote_extends, "--allow-remote-extends"),
3902 (cli.no_cache, "--no-cache"),
3903 (cli.threads.is_some(), "--threads"),
3904 (cli.changed_since.is_some(), "--changed-since"),
3905 (cli.diff_file.is_some(), "--diff-file"),
3906 (cli.diff_stdin, "--diff-stdin"),
3907 (cli.churn_file.is_some(), "--churn-file"),
3908 (cli.max_file_size.is_some(), "--max-file-size"),
3909 (cli.baseline.is_some(), "--baseline"),
3910 (cli.baseline_mode.is_some(), "--baseline-mode"),
3911 (cli.parent_run.is_some(), "--parent-run"),
3912 (cli.save_baseline.is_some(), "--save-baseline"),
3913 (cli.production, "--production"),
3914 (cli.no_production, "--no-production"),
3915 (cli.no_package_baselines, "--no-package-baselines"),
3916 (cli.production_dead_code, "--production-dead-code"),
3917 (cli.production_health, "--production-health"),
3918 (cli.production_dupes, "--production-dupes"),
3919 (cli.workspace.is_some(), "--workspace"),
3920 (cli.changed_workspaces.is_some(), "--changed-workspaces"),
3921 (cli.group_by.is_some(), "--group-by"),
3922 (cli.performance, "--performance"),
3923 (cli.explain, "--explain"),
3924 (cli.explain_skipped, "--explain-skipped"),
3925 (cli.summary, "--summary"),
3926 (cli.ci, "--ci"),
3927 (cli.fail_on_issues, "--fail-on-issues"),
3928 (cli.sarif_file.is_some(), "--sarif-file"),
3929 (cli.output_file.is_some(), "--output-file"),
3930 (cli.report_path_prefix.is_some(), "--report-path-prefix"),
3931 (cli.fail_on_regression, "--fail-on-regression"),
3932 (cli.fail_on_stale_baseline, "--fail-on-stale-baseline"),
3933 (cli.fail_on_baseline_growth, "--fail-on-baseline-growth"),
3934 (cli.baseline_base.is_some(), "--baseline-base"),
3935 (cli.fail_on_parse_error, "--fail-on-parse-error"),
3936 (cli.tolerance != "0", "--tolerance"),
3937 (cli.regression_baseline.is_some(), "--regression-baseline"),
3938 (
3939 cli.save_regression_baseline.is_some(),
3940 "--save-regression-baseline",
3941 ),
3942 (!cli.only.is_empty(), "--only"),
3943 (!cli.skip.is_empty(), "--skip"),
3944 (dupes_flag.is_some(), dupes_flag.unwrap_or_default()),
3945 (cli.score, "--score"),
3946 (cli.trend, "--trend"),
3947 (cli.trend_from.is_some(), "--trend-from"),
3948 (cli.save_snapshot.is_some(), "--save-snapshot"),
3949 (cli.coverage.is_some(), "--coverage"),
3950 (cli.coverage_root.is_some(), "--coverage-root"),
3951 (cli.dupes_baseline.is_some(), "--dupes-baseline"),
3952 (cli.health_baseline.is_some(), "--health-baseline"),
3953 (cli.include_entry_exports, "--include-entry-exports"),
3954 (cli.type_aware, "--type-aware"),
3955 (cli.no_type_aware, "--no-type-aware"),
3956 (!cli.type_aware_project.is_empty(), "--type-aware-project"),
3957 (cli.type_aware_require.is_some(), "--type-aware-require"),
3958 ]
3959 .into_iter()
3960 .find_map(|(used, flag)| used.then_some(flag))
3961}
3962
3963fn run_schema_command_if_requested(
3964 cli: &Cli,
3965 json_style: json_style::JsonStyle,
3966) -> Option<ExitCode> {
3967 match cli.command {
3968 Some(Command::Schema) => Some(schema::run_schema(json_style)),
3969 Some(Command::ConfigSchema) => Some(init::run_config_schema(json_style)),
3970 Some(Command::PluginSchema) => Some(init::run_plugin_schema(json_style)),
3971 Some(Command::RulePackSchema) => Some(init::run_rule_pack_schema(json_style)),
3972 _ => None,
3973 }
3974}
3975
3976fn regression_save_targets(cli: &Cli) -> (Option<std::path::PathBuf>, bool) {
3977 let save_file = cli.save_regression_baseline.as_ref().and_then(|opt| {
3978 opt.as_ref()
3979 .filter(|path| !path.is_empty())
3980 .map(std::path::PathBuf::from)
3981 });
3982 let save_to_config = cli.save_regression_baseline.is_some() && save_file.is_none();
3983 (save_file, save_to_config)
3984}
3985
3986fn dispatch_bare_command(dispatch: &DispatchContext<'_>) -> ExitCode {
3987 let cli = dispatch.cli;
3988 let (run_check, run_dupes, run_health) = combined::resolve_analyses(&cli.only, &cli.skip);
3989 let production = match dispatch.production_modes(
3990 cli.production_dead_code,
3991 cli.production_health,
3992 cli.production_dupes,
3993 ) {
3994 Ok(production) => production,
3995 Err(code) => return code,
3996 };
3997 let coverage_inputs = if run_health {
4002 match resolve_health_coverage_inputs(
4003 dispatch,
4004 cli.coverage.as_deref(),
4005 cli.coverage_root.as_deref(),
4006 ) {
4007 Ok(inputs) => inputs,
4008 Err(code) => return code,
4009 }
4010 } else {
4011 ResolvedHealthCoverageInputs::default()
4012 };
4013 run_bare_combined(
4014 dispatch,
4015 production,
4016 &coverage_inputs,
4017 BareAnalyses {
4018 run_check,
4019 run_dupes,
4020 run_health,
4021 },
4022 )
4023}
4024
4025#[derive(Clone, Copy)]
4027struct BareAnalyses {
4028 run_check: bool,
4029 run_dupes: bool,
4030 run_health: bool,
4031}
4032
4033fn run_bare_combined(
4036 dispatch: &DispatchContext<'_>,
4037 production: ProductionModes,
4038 coverage_inputs: &ResolvedHealthCoverageInputs,
4039 analyses: BareAnalyses,
4040) -> ExitCode {
4041 let cli = dispatch.cli;
4042 let output = dispatch.output;
4043 if cli.fail_on_parse_error && !analyses.run_check && !analyses.run_health {
4044 return error::emit_error_with_style(
4045 "--fail-on-parse-error needs the dead-code or health analysis, and this run analyzes neither. Include dead-code or health in --only or --skip, or remove the flag.",
4046 2,
4047 output,
4048 dispatch.json_style,
4049 );
4050 }
4051 let scope = match crate::scope_path::resolve_command_scope(
4052 dispatch.root,
4053 dispatch.output,
4054 cli.path.clone(),
4055 ) {
4056 Ok(scope) => scope.map(|resolved| resolved.absolute),
4057 Err(code) => return code,
4058 };
4059 let growth_targets = baseline_growth::targets(&[
4060 (
4061 cli.baseline.as_deref().filter(|_| analyses.run_check),
4062 fallow_engine::baseline::BaselineKind::DeadCode,
4063 ),
4064 (
4065 cli.dupes_baseline.as_deref().filter(|_| analyses.run_dupes),
4066 fallow_engine::baseline::BaselineKind::Dupes,
4067 ),
4068 (
4069 cli.health_baseline
4070 .as_deref()
4071 .filter(|_| analyses.run_health),
4072 fallow_engine::baseline::BaselineKind::Health,
4073 ),
4074 ]);
4075 baseline_growth::run_gated(
4076 &dispatch.growth_ctx(baseline_growth::GrowthOwner::Combined),
4077 &growth_targets,
4078 || run_combined_scoped(dispatch, production, coverage_inputs, analyses, scope),
4079 )
4080}
4081
4082fn run_combined_scoped(
4083 dispatch: &DispatchContext<'_>,
4084 production: ProductionModes,
4085 coverage_inputs: &ResolvedHealthCoverageInputs,
4086 analyses: BareAnalyses,
4087 scope: Option<PathBuf>,
4088) -> ExitCode {
4089 let cli = dispatch.cli;
4090 let (output, quiet, fail_on_issues) =
4091 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
4092 let scoped_run = scope.is_some();
4093 combined::run_combined(&combined::CombinedOptions {
4094 root: dispatch.root,
4095 config_path: &cli.config,
4096 output,
4097 json_style: dispatch.json_style,
4098 no_cache: cli.no_cache,
4099 threads: dispatch.threads,
4100 quiet,
4101 allow_remote_extends: cli.allow_remote_extends,
4102 fail_on_issues,
4103 sarif_file: cli.sarif_file.as_deref(),
4104 changed_since: cli.changed_since.as_deref(),
4105 no_package_baselines: cli.no_package_baselines,
4106 churn_file: cli.churn_file.as_deref(),
4107 baseline: cli.baseline.as_deref(),
4108 save_baseline: cli.save_baseline.as_deref(),
4109 dupes_baseline: cli.dupes_baseline.as_deref(),
4110 health_baseline: cli.health_baseline.as_deref(),
4111 health_baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
4112 health_baseline_mode_explicit: cli.baseline_mode.is_some(),
4113 fail_on_stale_baseline: cli.fail_on_stale_baseline,
4114 production: cli.production,
4115 production_dead_code: Some(production.dead_code),
4116 production_health: Some(production.health),
4117 production_dupes: Some(production.dupes),
4118 workspace: cli.workspace.as_deref(),
4119 changed_workspaces: cli.changed_workspaces.as_deref(),
4120 group_by: cli.group_by,
4121 type_aware: cli.type_aware_override(),
4122 type_aware_projects: &cli.type_aware_project,
4123 type_aware_require: cli.type_aware_require.map(Into::into),
4124 explain: cli.explain,
4125 explain_skipped: cli.explain_skipped,
4126 performance: cli.performance,
4127 summary: cli.summary,
4128 run_check: analyses.run_check,
4129 run_dupes: analyses.run_dupes,
4130 run_health: analyses.run_health,
4131 dupes: cli.dupes_overrides(),
4132 score: cli.score || cli.trend || cli.trend_from.is_some(),
4133 trend: cli.trend || cli.trend_from.is_some(),
4134 trend_from: cli.trend_from.as_deref(),
4135 save_snapshot: cli.save_snapshot.as_ref(),
4136 coverage: coverage_inputs.coverage.as_deref(),
4137 coverage_root: coverage_inputs.coverage_root.as_deref(),
4138 include_entry_exports: cli.include_entry_exports,
4139 fail_on_parse_error: cli.fail_on_parse_error,
4140 scope,
4141 regression_opts: dispatch.regression_opts(
4142 cli.changed_since.is_some()
4143 || cli.workspace.is_some()
4144 || cli.changed_workspaces.is_some()
4145 || scoped_run,
4146 ),
4147 })
4148}
4149
4150#[allow(
4151 clippy::too_many_lines,
4152 reason = "the command router is intentionally an exhaustive top-level dispatch table"
4153)]
4154fn dispatch_subcommand(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4155 let cli = dispatch.cli;
4156 let root = dispatch.root;
4157 let output = dispatch.output;
4158 let quiet = dispatch.quiet;
4159 match command {
4160 check @ Command::Check { .. } => dispatch_check_command(check, dispatch),
4161 Command::Watch { no_clear } => dispatch_watch(dispatch, no_clear),
4162 Command::TypeAware { subcommand } => dispatch_type_aware_command(dispatch, subcommand),
4163 Command::Doctor => unreachable!("doctor bypasses the normal dispatch epilogue"),
4164 Command::SimilarCode {
4165 subcommand,
4166 threshold,
4167 min_lines,
4168 top,
4169 file,
4170 path,
4171 } => {
4172 let scope = match crate::scope_path::resolve_command_scope(
4173 dispatch.root,
4174 dispatch.output,
4175 path,
4176 ) {
4177 Ok(scope) => scope,
4178 Err(code) => return code,
4179 };
4180 similar_code_cli::run(similar_code_cli::SimilarCodeCliInput {
4181 root,
4182 config_path: cli.config.as_deref(),
4183 allow_remote_extends: cli.allow_remote_extends,
4184 no_cache: cli.no_cache,
4185 threads: dispatch.threads,
4186 changed_since: cli.changed_since.as_deref(),
4187 diff_file: cli.diff_file.as_deref(),
4188 workspace: cli.workspace.as_deref(),
4189 changed_workspaces: cli.changed_workspaces.as_deref(),
4190 explain: cli.explain,
4191 quiet,
4192 output,
4193 json_style: dispatch.json_style,
4194 threshold,
4195 min_lines,
4196 top,
4197 files: file,
4198 scope,
4199 subcommand,
4200 })
4201 }
4202 Command::Inspect {
4203 file,
4204 symbol,
4205 symbol_chain,
4206 churn,
4207 } => dispatch_inspect_command(dispatch, file, symbol, symbol_chain, churn),
4208 Command::Trace {
4209 symbol,
4210 path,
4211 eager_only,
4212 callers,
4213 callees,
4214 depth,
4215 } => dispatch_trace_command(
4216 dispatch,
4217 symbol,
4218 &path,
4219 eager_only,
4220 TraceChainFlags {
4221 callers,
4222 callees,
4223 depth,
4224 },
4225 ),
4226 Command::TraceError { trace_file } => {
4227 trace_error::run_trace_error(&trace_error::TraceErrorOptions {
4228 root: dispatch.root,
4229 config_path: &dispatch.cli.config,
4230 output: dispatch.output,
4231 json_style: dispatch.json_style,
4232 no_cache: dispatch.cli.no_cache,
4233 threads: dispatch.threads,
4234 quiet: dispatch.quiet,
4235 allow_remote_extends: dispatch.cli.allow_remote_extends,
4236 trace_file: trace_file.as_deref(),
4237 })
4238 }
4239 fix @ Command::Fix { .. } => dispatch_fix_command(&fix, dispatch),
4240 init @ Command::Init { .. } => dispatch_init_command(init, root, quiet),
4241 Command::Hooks { subcommand } => {
4242 run_hooks_command(root, subcommand, output, dispatch.json_style)
4243 }
4244 Command::Agent { subcommand } => dispatch_agent_command(dispatch, subcommand),
4245 Command::Ci { subcommand } => {
4246 ci::run(map_ci_subcommand(subcommand), output, dispatch.json_style)
4247 }
4248 Command::ConfigSchema => init::run_config_schema(dispatch.json_style),
4249 Command::PluginSchema => init::run_plugin_schema(dispatch.json_style),
4250 Command::PluginCheck => plugin_check::run_plugin_check(root, output, dispatch.json_style),
4251 Command::RulePackSchema => init::run_rule_pack_schema(dispatch.json_style),
4252 Command::RulePack { subcommand } => dispatch_rule_pack_command(dispatch, subcommand),
4253 Command::Guard { files } => dispatch_guard_command(dispatch, &files),
4254 Command::CiTemplate { subcommand } => dispatch_ci_template_command(subcommand),
4255 Command::Config { path } => config::run_config_with_options(config::RunConfigInput {
4256 root,
4257 explicit_config: cli.config.as_deref(),
4258 path_only: path,
4259 output,
4260 quiet,
4261 json_style: dispatch.json_style,
4262 load_options: fallow_config::ConfigLoadOptions {
4263 allow_remote_extends: cli.allow_remote_extends,
4264 },
4265 }),
4266 Command::Recommend => onboarding::run_recommend(root, output, dispatch.json_style),
4267 list @ (Command::Workspaces | Command::List { .. }) => {
4268 dispatch_list_command(&list, dispatch)
4269 }
4270 dupes @ Command::Dupes { .. } => dispatch_dupes_command(dupes, dispatch),
4271 health @ Command::Health { .. } => dispatch_health_command(health, dispatch),
4272 Command::Flags {
4273 top,
4274 retirement,
4275 reasons,
4276 sort,
4277 flag_age,
4278 min_age,
4279 flag_state,
4280 max_flag_age,
4281 } => dispatch_flags_command(
4282 dispatch,
4283 top,
4284 retirement.then_some(flags::RetirementArgs {
4285 reasons,
4286 sort,
4287 flag_age,
4288 min_age,
4289 flag_state,
4290 max_flag_age,
4291 }),
4292 ),
4293 Command::Suppressions { file } => dispatch_suppressions_command(dispatch, &file),
4294 Command::Explain { issue_type } => {
4295 explain::run_explain(&issue_type.join(" "), output, dispatch.json_style)
4296 }
4297 audit @ Command::Audit { .. } => dispatch_audit_command(audit, dispatch),
4298 Command::AuditCache { subcommand } => dispatch_audit_cache_command(dispatch, &subcommand),
4299 Command::DecisionSurface { max_decisions } => {
4300 dispatch_decision_surface(dispatch, max_decisions)
4301 }
4302 Command::Impact {
4303 subcommand,
4304 all,
4305 sort,
4306 limit,
4307 } => dispatch_impact(
4308 root,
4309 quiet,
4310 output,
4311 dispatch.json_style,
4312 subcommand,
4313 ImpactCrossRepoOpts { all, sort, limit },
4314 ),
4315 security @ Command::Security { .. } => dispatch_security_command(security, dispatch),
4316 Command::Viz {
4317 output: viz_output,
4318 no_open,
4319 viz_format,
4320 } => dispatch_viz(dispatch, viz_output.as_deref(), no_open, viz_format),
4321 Command::Report { from } => {
4322 cli_report::run_report(&from, output, root, cli.config.as_deref(), cli.quiet)
4323 }
4324 Command::Schema => unreachable!("handled above"),
4325 migrate @ Command::Migrate { .. } => dispatch_migrate_command(migrate, root),
4326 Command::License { subcommand } => {
4327 dispatch_license_command(subcommand, output, dispatch.json_style)
4328 }
4329 Command::Telemetry { .. } => unreachable!("handled before root validation"),
4330 Command::Coverage { subcommand } => dispatch_coverage_command(dispatch, &subcommand),
4331 setup_hooks @ Command::SetupHooks { .. } => {
4332 dispatch_setup_hooks_command(&setup_hooks, dispatch)
4333 }
4334 }
4335}
4336
4337fn dispatch_type_aware_command(
4338 dispatch: &DispatchContext<'_>,
4339 subcommand: TypeAwareCli,
4340) -> ExitCode {
4341 match subcommand {
4342 TypeAwareCli::Status => {
4343 let status = fallow_api::type_aware_status(dispatch.root);
4344 match dispatch.output {
4345 fallow_config::OutputFormat::Json => {
4346 let output = type_aware_status_output(dispatch.root, status);
4347 match fallow_output::serialize_type_aware_status_json_output(output) {
4348 Ok(value) => match dispatch.json_style.serialize(&value) {
4349 Ok(json) => {
4350 crate::report::sink::outln!("{json}");
4351 ExitCode::SUCCESS
4352 }
4353 Err(error) => emit_error(
4354 &format!("failed to serialize type-aware status: {error}"),
4355 2,
4356 dispatch.output,
4357 ),
4358 },
4359 Err(error) => emit_error(
4360 &format!("failed to build type-aware status: {error}"),
4361 2,
4362 dispatch.output,
4363 ),
4364 }
4365 }
4366 fallow_config::OutputFormat::Human => {
4367 if status.available {
4368 crate::report::sink::outln!(
4369 "{}",
4370 report::human_status_line(
4371 report::HumanStatus::Ok,
4372 format_args!(
4373 "Type-aware companion: available ({}, protocol {}, TypeScript {})",
4374 status.package_version.as_deref().unwrap_or("unknown"),
4375 status.protocol_version,
4376 status.backend_version.as_deref().unwrap_or("unknown"),
4377 )
4378 )
4379 );
4380 } else {
4381 crate::report::sink::outln!(
4382 "{}",
4383 report::human_status_line(
4384 report::HumanStatus::Inactive,
4385 "Type-aware companion: unavailable"
4386 )
4387 );
4388 if let Some(remediation) = status.remediation {
4389 crate::report::sink::outln!(
4390 "{}",
4391 report::human_status_line(
4392 report::HumanStatus::Warning,
4393 format_args!("Action: {remediation}")
4394 )
4395 );
4396 }
4397 }
4398 ExitCode::SUCCESS
4399 }
4400 _ => emit_error(
4401 "type-aware status supports human and json output",
4402 2,
4403 dispatch.output,
4404 ),
4405 }
4406 }
4407 }
4408}
4409
4410fn type_aware_status_output(
4411 root: &Path,
4412 status: fallow_api::TypeAwareStatus,
4413) -> fallow_output::TypeAwareStatusOutput {
4414 let companion_path = status.companion_path.as_deref().map(|path| {
4415 if let Ok(relative) = path.strip_prefix(root)
4416 && !relative.as_os_str().is_empty()
4417 {
4418 relative.to_string_lossy().replace('\\', "/")
4419 } else {
4420 path.file_name()
4421 .unwrap_or(path.as_os_str())
4422 .to_string_lossy()
4423 .into_owned()
4424 }
4425 });
4426 let remediation = status.remediation.map(|message| {
4427 let without_root = message.replace(root.to_string_lossy().as_ref(), ".");
4428 status.companion_path.as_deref().map_or_else(
4429 || without_root.clone(),
4430 |path| {
4431 without_root.replace(
4432 path.to_string_lossy().as_ref(),
4433 companion_path.as_deref().unwrap_or("fallow-type-aware"),
4434 )
4435 },
4436 )
4437 });
4438 fallow_output::TypeAwareStatusOutput {
4439 schema_version: fallow_types::envelope::SchemaVersion(
4440 fallow_output::TYPE_AWARE_STATUS_SCHEMA_VERSION,
4441 ),
4442 version: fallow_types::envelope::ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
4443 available: status.available,
4444 discovery_source: status.discovery_source.map(str::to_string),
4445 companion_path,
4446 package_version: status.package_version,
4447 protocol_version: status.protocol_version,
4448 backend_family: status.backend_family,
4449 backend_version: status.backend_version,
4450 remediation,
4451 }
4452}
4453
4454fn dispatch_check_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4456 let filters = check_issue_filters(&command);
4457 let Command::Check {
4458 include_dupes,
4459 trace,
4460 trace_file,
4461 trace_dependency,
4462 impact_closure,
4463 symbol_impact,
4464 top,
4465 file,
4466 finding_id,
4467 path,
4468 ..
4469 } = command
4470 else {
4471 unreachable!("check dispatcher only handles check commands");
4472 };
4473
4474 let finding_ids = match fallow_engine::dead_code::FindingIdFilter::parse(&finding_id) {
4475 Ok(filter) => filter,
4476 Err(message) => return emit_error(&format!("--finding-id: {message}"), 2, dispatch.output),
4477 };
4478
4479 let scope = match crate::scope_path::resolve_command_scope(dispatch.root, dispatch.output, path)
4480 {
4481 Ok(scope) => scope.map(|resolved| resolved.absolute),
4482 Err(code) => return code,
4483 };
4484
4485 dispatch_check(
4486 dispatch,
4487 &CheckDispatchArgs {
4488 filters,
4489 trace_opts: TraceOptions {
4490 trace_export: trace,
4491 trace_file,
4492 trace_dependency,
4493 impact_closure,
4494 symbol_impact,
4495 performance: dispatch.cli.performance,
4496 },
4497 include_dupes,
4498 type_aware: dispatch.cli.type_aware_override(),
4499 type_aware_project: dispatch.cli.type_aware_project.clone(),
4500 type_aware_require: dispatch.cli.type_aware_require,
4501 top,
4502 file,
4503 scope,
4504 finding_ids,
4505 },
4506 )
4507}
4508
4509fn check_issue_filters(command: &Command) -> IssueFilters {
4514 check_issue_filters_framework(command, &check_issue_filters_core(command))
4515}
4516
4517fn check_issue_filters_core(command: &Command) -> IssueFilters {
4520 let Command::Check {
4521 unused_files,
4522 unused_exports,
4523 unused_deps,
4524 unused_types,
4525 private_type_leaks,
4526 deprecated_exports_in_use,
4527 unused_enum_members,
4528 unused_class_members,
4529 unresolved_imports,
4530 unlisted_deps,
4531 duplicate_exports,
4532 circular_deps,
4533 re_export_cycles,
4534 package_cycles,
4535 boundary_violations,
4536 policy_violations,
4537 stale_suppressions,
4538 ..
4539 } = command
4540 else {
4541 unreachable!("check filter builder only handles check commands");
4542 };
4543
4544 let mut filters = IssueFilters::default();
4545 for (flag, active) in [
4546 ("--unused-files", *unused_files),
4547 ("--unused-exports", *unused_exports),
4548 ("--unused-deps", *unused_deps),
4549 ("--unused-types", *unused_types),
4550 ("--private-type-leaks", *private_type_leaks),
4551 ("--deprecated-exports-in-use", *deprecated_exports_in_use),
4552 ("--unused-enum-members", *unused_enum_members),
4553 ("--unused-class-members", *unused_class_members),
4554 ("--unresolved-imports", *unresolved_imports),
4555 ("--unlisted-deps", *unlisted_deps),
4556 ("--duplicate-exports", *duplicate_exports),
4557 ("--circular-deps", *circular_deps),
4558 ("--re-export-cycles", *re_export_cycles),
4559 ("--package-cycles", *package_cycles),
4560 ("--boundary-violations", *boundary_violations),
4561 ("--policy-violations", *policy_violations),
4562 ("--stale-suppressions", *stale_suppressions),
4563 ] {
4564 enable_check_filter(&mut filters, flag, active);
4565 }
4566 filters
4567}
4568
4569fn check_issue_filters_framework(command: &Command, base: &IssueFilters) -> IssueFilters {
4572 let Command::Check {
4573 unused_store_members,
4574 unprovided_injects,
4575 unrendered_components,
4576 unused_component_props,
4577 absent_component_props,
4578 unused_component_emits,
4579 unused_component_inputs,
4580 unused_component_outputs,
4581 unused_svelte_events,
4582 unused_server_actions,
4583 unused_load_data_keys,
4584 unused_catalog_entries,
4585 empty_catalog_groups,
4586 unresolved_catalog_references,
4587 unused_dependency_overrides,
4588 misconfigured_dependency_overrides,
4589 ..
4590 } = command
4591 else {
4592 unreachable!("check filter builder only handles check commands");
4593 };
4594
4595 let mut filters = base.clone();
4596 for (flag, active) in [
4597 ("--unused-store-members", *unused_store_members),
4598 ("--unprovided-injects", *unprovided_injects),
4599 ("--unrendered-components", *unrendered_components),
4600 ("--unused-component-props", *unused_component_props),
4601 ("--absent-component-props", *absent_component_props),
4602 ("--unused-component-emits", *unused_component_emits),
4603 ("--unused-component-inputs", *unused_component_inputs),
4604 ("--unused-component-outputs", *unused_component_outputs),
4605 ("--unused-svelte-events", *unused_svelte_events),
4606 ("--unused-server-actions", *unused_server_actions),
4607 ("--unused-load-data-keys", *unused_load_data_keys),
4608 ("--unused-catalog-entries", *unused_catalog_entries),
4609 ("--empty-catalog-groups", *empty_catalog_groups),
4610 (
4611 "--unresolved-catalog-references",
4612 *unresolved_catalog_references,
4613 ),
4614 (
4615 "--unused-dependency-overrides",
4616 *unused_dependency_overrides,
4617 ),
4618 (
4619 "--misconfigured-dependency-overrides",
4620 *misconfigured_dependency_overrides,
4621 ),
4622 ] {
4623 enable_check_filter(&mut filters, flag, active);
4624 }
4625 filters
4626}
4627
4628fn enable_check_filter(filters: &mut IssueFilters, flag: &str, active: bool) {
4629 if active {
4630 assert!(
4631 filters.enable_cli_filter_flag(flag),
4632 "check command uses unregistered dead-code filter flag {flag}"
4633 );
4634 }
4635}
4636
4637fn dispatch_inspect_command(
4638 dispatch: &DispatchContext<'_>,
4639 file: Option<String>,
4640 symbol: Option<String>,
4641 symbol_chain: bool,
4642 churn: bool,
4643) -> ExitCode {
4644 let target = match (file, symbol) {
4645 (Some(file), None) => inspect::InspectTarget::File { file },
4646 (None, Some(symbol)) => match selector::parse_file_symbol_selector(&symbol) {
4647 Some((file, export_name)) => inspect::InspectTarget::Symbol {
4648 file: file.to_string(),
4649 export_name: export_name.to_string(),
4650 },
4651 None => {
4652 return emit_error(
4653 "--symbol must be formatted as FILE:EXPORT",
4654 2,
4655 dispatch.output,
4656 );
4657 }
4658 },
4659 _ => {
4660 return emit_error(
4661 "inspect requires exactly one of --file or --symbol",
4662 2,
4663 dispatch.output,
4664 );
4665 }
4666 };
4667
4668 let churn_config = if churn {
4669 match load_config_for_analysis(
4670 dispatch.root,
4671 &dispatch.cli.config,
4672 ConfigLoadOptions {
4673 output: dispatch.output,
4674 no_cache: dispatch.cli.no_cache,
4675 threads: dispatch.threads,
4676 production_override: None,
4677 quiet: dispatch.quiet,
4678 allow_remote_extends: dispatch.cli.allow_remote_extends,
4679 },
4680 fallow_config::ProductionAnalysis::Health,
4681 ) {
4682 Ok(config) => Some(config),
4683 Err(code) => return code,
4684 }
4685 } else {
4686 None
4687 };
4688
4689 inspect::run_inspect(&inspect::InspectOptions {
4690 root: dispatch.root,
4691 config_path: dispatch.cli.config.as_ref(),
4692 output: dispatch.output,
4693 json_style: dispatch.json_style,
4694 no_cache: dispatch.cli.no_cache,
4695 no_production: dispatch.cli.no_production,
4696 max_file_size: dispatch.cli.max_file_size,
4697 threads: dispatch.threads,
4698 quiet: dispatch.quiet,
4699 production: dispatch.cli.production,
4700 workspace: dispatch.cli.workspace.as_ref(),
4701 target,
4702 churn_cache_dir: churn_config
4703 .as_ref()
4704 .map(|config| config.cache_dir.as_path()),
4705 symbol_chain,
4706 type_aware: dispatch.cli.type_aware_override(),
4707 type_aware_projects: &dispatch.cli.type_aware_project,
4708 type_aware_require: dispatch.cli.type_aware_require.map(Into::into),
4709 })
4710}
4711
4712#[derive(Clone, Copy)]
4714struct TraceChainFlags {
4715 callers: bool,
4716 callees: bool,
4717 depth: Option<u32>,
4718}
4719
4720fn dispatch_trace_command(
4721 dispatch: &DispatchContext<'_>,
4722 symbol: Option<String>,
4723 path: &[String],
4724 eager_only: bool,
4725 chain: TraceChainFlags,
4726) -> ExitCode {
4727 let TraceChainFlags {
4728 callers,
4729 callees,
4730 depth,
4731 } = chain;
4732 if let [from, to] = path {
4733 return trace_path::run_trace_path(&trace_path::TracePathOptions {
4734 root: dispatch.root,
4735 config_path: &dispatch.cli.config,
4736 output: dispatch.output,
4737 json_style: dispatch.json_style,
4738 no_cache: dispatch.cli.no_cache,
4739 threads: dispatch.threads,
4740 quiet: dispatch.quiet,
4741 allow_remote_extends: dispatch.cli.allow_remote_extends,
4742 from,
4743 to,
4744 eager_only,
4745 });
4746 }
4747 let Some(symbol) = symbol else {
4748 return emit_error(
4749 "trace requires a FILE:SYMBOL target or --path <FROM> <TO>",
4750 2,
4751 dispatch.output,
4752 );
4753 };
4754 trace_chain::run_trace(&trace_chain::TraceChainOptions {
4755 root: dispatch.root,
4756 config_path: &dispatch.cli.config,
4757 output: dispatch.output,
4758 json_style: dispatch.json_style,
4759 no_cache: dispatch.cli.no_cache,
4760 threads: dispatch.threads,
4761 quiet: dispatch.quiet,
4762 allow_remote_extends: dispatch.cli.allow_remote_extends,
4763 target: symbol,
4764 callers,
4765 callees,
4766 depth: depth.unwrap_or(fallow_types::trace_chain::DEFAULT_TRACE_DEPTH),
4767 })
4768}
4769
4770fn dispatch_security_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4771 let Command::Security {
4772 subcommand,
4773 runtime_coverage,
4774 min_invocations_hot,
4775 file,
4776 gate,
4777 surface,
4778 path,
4779 } = command
4780 else {
4781 unreachable!("security dispatcher only handles security commands");
4782 };
4783
4784 let scope = match crate::scope_path::resolve_command_scope(dispatch.root, dispatch.output, path)
4785 {
4786 Ok(scope) => scope.map(|resolved| resolved.absolute),
4787 Err(code) => return code,
4788 };
4789
4790 let gate = gate.map(security::SecurityGateArg::into_mode);
4791 let cli = dispatch.cli;
4792 let (output, _quiet, fail_on_issues) =
4793 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
4794 let derived_flags = SecurityDerivedFlagState {
4795 output,
4796 json_style: dispatch.json_style,
4797 ci: cli.ci,
4798 fail_on_issues,
4799 sarif_file: cli.sarif_file.as_deref(),
4800 summary: cli.summary,
4801 explain: cli.explain,
4802 runtime_coverage: runtime_coverage.as_deref(),
4803 min_invocations_hot,
4804 file: file.as_slice(),
4805 gate,
4806 surface,
4807 };
4808 if let Some(code) = try_run_security_survivors(subcommand.as_ref(), &derived_flags) {
4809 return code;
4810 }
4811
4812 let scoped_files = scoped_security_files(&file, subcommand.as_ref());
4813 run_security_blind_spots_or_default(
4814 dispatch,
4815 &SecurityRunInputs {
4816 scoped_files: &scoped_files,
4817 subcommand: &subcommand,
4818 runtime_coverage: runtime_coverage.as_deref(),
4819 min_invocations_hot,
4820 gate,
4821 surface,
4822 scope,
4823 },
4824 &derived_flags,
4825 )
4826}
4827
4828struct SecurityRunInputs<'a> {
4831 scoped_files: &'a [PathBuf],
4832 subcommand: &'a Option<SecuritySubcommand>,
4833 runtime_coverage: Option<&'a Path>,
4834 min_invocations_hot: u64,
4835 gate: Option<security::SecurityGateMode>,
4836 surface: bool,
4837 scope: Option<PathBuf>,
4838}
4839
4840fn run_security_blind_spots_or_default(
4842 dispatch: &DispatchContext<'_>,
4843 inputs: &SecurityRunInputs<'_>,
4844 derived_flags: &SecurityDerivedFlagState<'_>,
4845) -> ExitCode {
4846 let cli = dispatch.cli;
4847 let (output, quiet, fail_on_issues) =
4848 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
4849 let opts = security::SecurityOptions {
4850 root: dispatch.root,
4851 config_path: &cli.config,
4852 output,
4853 json_style: dispatch.json_style,
4854 no_cache: cli.no_cache,
4855 threads: dispatch.threads,
4856 quiet,
4857 allow_remote_extends: cli.allow_remote_extends,
4858 fail_on_issues,
4859 sarif_file: cli.sarif_file.as_deref(),
4860 summary: cli.summary,
4861 changed_since: cli.changed_since.as_deref(),
4862 use_shared_diff_index: true,
4863 workspace: cli.workspace.as_deref(),
4864 changed_workspaces: cli.changed_workspaces.as_deref(),
4865 file: inputs.scoped_files,
4866 surface: inputs.surface,
4867 scope: inputs.scope.clone(),
4868 gate: inputs.gate,
4869 runtime_coverage: inputs.runtime_coverage,
4870 min_invocations_hot: inputs.min_invocations_hot,
4871 explain: cli.explain,
4872 };
4873 if matches!(
4874 inputs.subcommand,
4875 Some(SecuritySubcommand::BlindSpots { .. })
4876 ) {
4877 if let Some(code) = validate_security_blind_spots_flags(derived_flags) {
4878 return code;
4879 }
4880 security::run_blind_spots(&opts)
4881 } else {
4882 security::run(&opts)
4883 }
4884}
4885
4886fn try_run_security_survivors(
4889 subcommand: Option<&SecuritySubcommand>,
4890 flags: &SecurityDerivedFlagState<'_>,
4891) -> Option<ExitCode> {
4892 let Some(SecuritySubcommand::Survivors {
4893 candidates,
4894 verdicts,
4895 require_verdict_for_each_candidate,
4896 }) = subcommand
4897 else {
4898 return None;
4899 };
4900 if let Some(code) = validate_security_survivors_flags(flags) {
4901 return Some(code);
4902 }
4903 Some(security::run_survivors(
4904 &security::SecuritySurvivorsOptions {
4905 output: flags.output,
4906 json_style: flags.json_style,
4907 candidates,
4908 verdicts,
4909 require_verdict_for_each_candidate: *require_verdict_for_each_candidate,
4910 },
4911 ))
4912}
4913
4914fn scoped_security_files(
4916 file: &[PathBuf],
4917 subcommand: Option<&SecuritySubcommand>,
4918) -> Vec<PathBuf> {
4919 let mut scoped_files = file.to_vec();
4920 if let Some(SecuritySubcommand::BlindSpots {
4921 file: blind_spot_files,
4922 }) = subcommand
4923 {
4924 scoped_files.extend(blind_spot_files.iter().cloned());
4925 }
4926 scoped_files
4927}
4928
4929struct SecurityDerivedFlagState<'a> {
4930 output: fallow_config::OutputFormat,
4931 json_style: json_style::JsonStyle,
4932 ci: bool,
4933 fail_on_issues: bool,
4934 sarif_file: Option<&'a Path>,
4935 summary: bool,
4936 explain: bool,
4937 runtime_coverage: Option<&'a Path>,
4938 min_invocations_hot: u64,
4939 file: &'a [PathBuf],
4940 gate: Option<security::SecurityGateMode>,
4941 surface: bool,
4942}
4943
4944fn validate_security_survivors_flags(flags: &SecurityDerivedFlagState<'_>) -> Option<ExitCode> {
4945 let flag = if flags.ci {
4946 Some("--ci")
4947 } else if flags.fail_on_issues {
4948 Some("--fail-on-issues")
4949 } else if flags.sarif_file.is_some() {
4950 Some("--sarif-file")
4951 } else if flags.summary {
4952 Some("--summary")
4953 } else if flags.explain {
4954 Some("--explain")
4955 } else if flags.runtime_coverage.is_some() {
4956 Some("--runtime-coverage")
4957 } else if flags.min_invocations_hot != DEFAULT_MIN_INVOCATIONS_HOT {
4958 Some("--min-invocations-hot")
4959 } else if !flags.file.is_empty() {
4960 Some("--file")
4961 } else if flags.gate.is_some() {
4962 Some("--gate")
4963 } else if flags.surface {
4964 Some("--surface")
4965 } else {
4966 None
4967 }?;
4968 Some(emit_error(
4969 &format!("{flag} is not valid with `fallow security survivors`."),
4970 2,
4971 flags.output,
4972 ))
4973}
4974
4975fn validate_security_blind_spots_flags(flags: &SecurityDerivedFlagState<'_>) -> Option<ExitCode> {
4976 let flag = if flags.ci {
4977 Some("--ci")
4978 } else if flags.fail_on_issues {
4979 Some("--fail-on-issues")
4980 } else if flags.sarif_file.is_some() {
4981 Some("--sarif-file")
4982 } else if flags.summary {
4983 Some("--summary")
4984 } else if flags.explain {
4985 Some("--explain")
4986 } else if flags.runtime_coverage.is_some() {
4987 Some("--runtime-coverage")
4988 } else if flags.min_invocations_hot != DEFAULT_MIN_INVOCATIONS_HOT {
4989 Some("--min-invocations-hot")
4990 } else if flags.gate.is_some() {
4991 Some("--gate")
4992 } else if flags.surface {
4993 Some("--surface")
4994 } else {
4995 None
4996 }?;
4997 Some(emit_error(
4998 &format!("{flag} is not valid with `fallow security blind-spots`."),
4999 2,
5000 flags.output,
5001 ))
5002}
5003
5004fn dispatch_dupes_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
5005 let Command::Dupes {
5006 mode,
5007 near,
5008 min_tokens,
5009 min_lines,
5010 min_occurrences,
5011 threshold,
5012 skip_local,
5013 ignore_symlinks,
5014 no_ignore_symlinks,
5015 cross_language,
5016 ignore_imports,
5017 no_ignore_imports,
5018 top,
5019 no_fragments,
5020 trace,
5021 path,
5022 } = command
5023 else {
5024 unreachable!("dupes dispatcher only handles dupes commands");
5025 };
5026
5027 let scope = match crate::scope_path::resolve_command_scope(dispatch.root, dispatch.output, path)
5028 {
5029 Ok(scope) => scope.map(|resolved| resolved.absolute),
5030 Err(code) => return code,
5031 };
5032
5033 dispatch_dupes(
5034 dispatch,
5035 &DupesDispatchArgs {
5036 overrides: DupesOverrides {
5037 mode,
5038 near,
5039 threshold,
5040 min_tokens,
5041 min_lines,
5042 min_occurrences,
5043 skip_local,
5044 ignore_symlinks: resolve_negatable_flag(ignore_symlinks, no_ignore_symlinks),
5045 cross_language,
5046 ignore_imports: resolve_negatable_flag(ignore_imports, no_ignore_imports),
5047 },
5048 top,
5049 no_fragments,
5050 trace,
5051 scope,
5052 },
5053 )
5054}
5055
5056fn dispatch_agent_command(dispatch: &DispatchContext<'_>, subcommand: AgentCli) -> ExitCode {
5057 run_agent_command(
5058 dispatch.root,
5059 dispatch.cli.root.is_some(),
5060 subcommand,
5061 dispatch.output,
5062 dispatch.json_style,
5063 )
5064}
5065
5066fn dispatch_init_command(command: Command, root: &Path, quiet: bool) -> ExitCode {
5067 let Command::Init {
5068 toml,
5069 agents,
5070 hooks,
5071 branch,
5072 decline,
5073 } = command
5074 else {
5075 unreachable!("init dispatcher only handles init commands");
5076 };
5077
5078 init::run_init(&init::InitOptions {
5079 root,
5080 use_toml: toml,
5081 agents,
5082 hooks,
5083 branch: branch.as_deref(),
5084 decline,
5085 quiet,
5086 })
5087}
5088
5089fn dispatch_fix_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
5090 let Command::Fix {
5091 dry_run,
5092 yes,
5093 no_create_config,
5094 path,
5095 } = command
5096 else {
5097 unreachable!("fix dispatcher only handles fix commands");
5098 };
5099
5100 let scope = match crate::scope_path::resolve_command_scope(
5101 dispatch.root,
5102 dispatch.output,
5103 path.clone(),
5104 ) {
5105 Ok(scope) => scope.map(|resolved| resolved.absolute),
5106 Err(code) => return code,
5107 };
5108
5109 dispatch_fix(
5110 dispatch,
5111 &FixDispatchArgs {
5112 dry_run: *dry_run,
5113 yes: *yes,
5114 no_create_config: *no_create_config,
5115 scope,
5116 },
5117 )
5118}
5119
5120fn dispatch_list_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
5121 match command {
5122 Command::Workspaces => dispatch_list(dispatch, &ListDispatchArgs::workspaces()),
5123 Command::List {
5124 entry_points,
5125 files,
5126 plugins,
5127 boundaries,
5128 workspaces,
5129 entry_weight,
5130 path,
5131 } => {
5132 let scope = match crate::scope_path::resolve_command_scope(
5133 dispatch.root,
5134 dispatch.output,
5135 path.clone(),
5136 ) {
5137 Ok(scope) => scope.map(|resolved| resolved.absolute),
5138 Err(code) => return code,
5139 };
5140 dispatch_list(
5141 dispatch,
5142 &ListDispatchArgs {
5143 entry_points: *entry_points,
5144 files: *files,
5145 plugins: *plugins,
5146 boundaries: *boundaries,
5147 workspaces: *workspaces,
5148 entry_weight: *entry_weight,
5149 scope,
5150 },
5151 )
5152 }
5153 _ => unreachable!("list dispatcher only handles list commands"),
5154 }
5155}
5156
5157fn dispatch_migrate_command(command: Command, root: &Path) -> ExitCode {
5158 let Command::Migrate {
5159 toml,
5160 jsonc,
5161 dry_run,
5162 from,
5163 } = command
5164 else {
5165 unreachable!("migrate dispatcher only handles migrate commands");
5166 };
5167
5168 migrate::run_migrate(root, toml, jsonc, dry_run, from.as_deref())
5169}
5170
5171fn dispatch_license_command(
5172 subcommand: LicenseCli,
5173 output: fallow_config::OutputFormat,
5174 json_style: json_style::JsonStyle,
5175) -> ExitCode {
5176 license::run(&map_license_subcommand(subcommand), output, json_style)
5177}
5178
5179fn dispatch_ci_template_command(subcommand: CiTemplateCli) -> ExitCode {
5180 match subcommand {
5181 CiTemplateCli::Gitlab { vendor, force } => {
5182 ci_template::run_gitlab_template(&ci_template::GitlabTemplateOptions {
5183 vendor_dir: vendor,
5184 force,
5185 })
5186 }
5187 }
5188}
5189
5190fn dispatch_coverage_command(dispatch: &DispatchContext<'_>, subcommand: &CoverageCli) -> ExitCode {
5191 let cli = dispatch.cli;
5192 coverage::run(
5193 map_coverage_subcommand(subcommand, cli.explain, cli.changed_since.as_deref()),
5194 &coverage::RunContext {
5195 root: dispatch.root,
5196 config_path: &cli.config,
5197 output: dispatch.output,
5198 json_style: dispatch.json_style,
5199 quiet: dispatch.quiet,
5200 no_cache: cli.no_cache,
5201 threads: dispatch.threads,
5202 explain: cli.explain,
5203 allow_remote_extends: cli.allow_remote_extends,
5204 },
5205 )
5206}
5207
5208fn dispatch_health_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
5209 let Command::Health {
5210 max_cyclomatic,
5211 max_cognitive,
5212 max_crap,
5213 top,
5214 sort,
5215 complexity,
5216 complexity_breakdown,
5217 file_scores,
5218 coverage_gaps,
5219 hotspots,
5220 ownership,
5221 ownership_emails,
5222 targets,
5223 type_coupling,
5224 css,
5225 effort,
5226 score,
5227 min_score,
5228 min_severity,
5229 report_only,
5230 since,
5231 min_commits,
5232 save_snapshot,
5233 trend,
5234 trend_from,
5235 coverage,
5236 coverage_root,
5237 runtime_coverage,
5238 min_invocations_hot,
5239 min_observation_volume,
5240 low_traffic_threshold,
5241 path,
5242 } = command
5243 else {
5244 unreachable!("health dispatcher only handles health commands");
5245 };
5246
5247 let scope = match crate::scope_path::resolve_command_scope(dispatch.root, dispatch.output, path)
5248 {
5249 Ok(scope) => scope.map(|resolved| resolved.absolute),
5250 Err(code) => return code,
5251 };
5252
5253 let ownership = ownership || ownership_emails.is_some();
5254 let hotspots = hotspots || ownership;
5255 let args = HealthDispatchArgs {
5256 max_cyclomatic,
5257 max_cognitive,
5258 max_crap,
5259 top,
5260 sort,
5261 complexity,
5262 complexity_breakdown,
5263 file_scores,
5264 coverage_gaps,
5265 hotspots,
5266 ownership,
5267 ownership_emails: ownership_emails.map(EmailModeArg::to_config),
5268 targets,
5269 type_coupling,
5270 css,
5271 effort,
5272 score,
5273 min_score,
5274 min_severity: min_severity.map(HealthSeverityCli::to_health_severity),
5275 report_only,
5276 since: since.as_deref(),
5277 min_commits,
5278 save_snapshot: save_snapshot.as_ref(),
5279 trend: trend || trend_from.is_some(),
5280 trend_from: trend_from.as_deref(),
5281 fail_on_stale_baseline: dispatch.cli.fail_on_stale_baseline,
5282 fail_on_parse_error: dispatch.cli.fail_on_parse_error,
5283 coverage: coverage.as_deref(),
5284 coverage_root: coverage_root.as_deref(),
5285 runtime_coverage: runtime_coverage.as_deref(),
5286 min_invocations_hot,
5287 min_observation_volume,
5288 low_traffic_threshold,
5289 scope,
5290 };
5291 dispatch_health(dispatch, &args)
5292}
5293
5294fn dispatch_setup_hooks_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
5295 let Command::SetupHooks {
5296 agent,
5297 dry_run,
5298 force,
5299 user,
5300 gitignore_claude,
5301 uninstall,
5302 } = command
5303 else {
5304 unreachable!("setup-hooks dispatcher only handles setup-hooks commands");
5305 };
5306
5307 eprintln!(
5308 "warning: `fallow setup-hooks` is deprecated and will be removed in the next major; use `fallow agent install` or `fallow hooks install --target agent`."
5309 );
5310 setup_hooks::run_setup_hooks(&setup_hooks::SetupHooksOptions {
5311 root: dispatch.root,
5312 agent: *agent,
5313 dry_run: *dry_run,
5314 force: *force,
5315 user: *user,
5316 home: None,
5317 gitignore_claude: *gitignore_claude,
5318 uninstall: *uninstall,
5319 })
5320}
5321
5322fn dispatch_audit_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
5323 let Command::Audit {
5324 production_dead_code,
5325 production_health,
5326 production_dupes,
5327 dead_code_baseline,
5328 health_baseline,
5329 dupes_baseline,
5330 max_crap,
5331 coverage,
5332 coverage_root,
5333 no_css,
5334 css_deep,
5335 no_css_deep,
5336 gate,
5337 runtime_coverage,
5338 min_invocations_hot,
5339 gate_marker,
5340 brief,
5341 max_decisions,
5342 walkthrough_guide,
5343 walkthrough_file,
5344 walkthrough,
5345 mark_viewed,
5346 show_cleared,
5347 show_deprioritized,
5348 path,
5349 } = command
5350 else {
5351 unreachable!("audit dispatcher only handles audit commands");
5352 };
5353
5354 let brief = brief || walkthrough_guide || walkthrough || walkthrough_file.is_some();
5357
5358 let scope = match crate::scope_path::resolve_command_scope(dispatch.root, dispatch.output, path)
5359 {
5360 Ok(scope) => scope.map(|resolved| resolved.absolute),
5361 Err(code) => return code,
5362 };
5363
5364 dispatch_audit(
5365 dispatch,
5366 &AuditDispatchArgs {
5367 production_dead_code,
5368 production_health,
5369 production_dupes,
5370 dead_code_baseline,
5371 health_baseline,
5372 dupes_baseline,
5373 max_crap,
5374 coverage,
5375 coverage_root,
5376 no_css,
5377 css_deep,
5378 no_css_deep,
5379 gate,
5380 runtime_coverage,
5381 min_invocations_hot,
5382 gate_marker,
5383 brief,
5384 max_decisions,
5385 walkthrough_guide,
5386 walkthrough_file,
5387 walkthrough,
5388 mark_viewed,
5389 show_cleared,
5390 show_deprioritized,
5391 scope,
5392 },
5393 )
5394}
5395
5396fn dispatch_audit_cache_command(
5397 dispatch: &DispatchContext<'_>,
5398 subcommand: &AuditCacheCli,
5399) -> ExitCode {
5400 match subcommand {
5401 AuditCacheCli::Remove { dry_run, yes } => {
5402 if !*dry_run && !*yes && !std::io::stdin().is_terminal() {
5403 return emit_error(
5404 "audit-cache remove requires --yes (or --force) in non-interactive environments. Use --dry-run to preview removal first, then pass --yes to confirm.",
5405 2,
5406 dispatch.output,
5407 );
5408 }
5409 match base_worktree::remove_reusable_audit_caches(dispatch.root, *dry_run) {
5410 Ok(report) => {
5411 let action = if *dry_run { "would remove" } else { "removed" };
5412 if matches!(dispatch.output, fallow_config::OutputFormat::Json) {
5413 let value = serde_json::json!({
5414 "kind": "audit-cache-remove",
5415 "schema_version": 1,
5416 "command": "audit-cache remove",
5417 "root": dispatch.root,
5418 "dry_run": report.dry_run,
5419 "found": report.found,
5420 "would_remove": report.found.saturating_sub(report.skipped),
5421 "removed": report.removed,
5422 "skipped": report.skipped,
5423 "complete": report.skipped == 0,
5424 });
5425 let output_code = report::emit_report_json(
5426 &value,
5427 "audit cache removal",
5428 dispatch.json_style,
5429 );
5430 if output_code != ExitCode::SUCCESS {
5431 return output_code;
5432 }
5433 } else if !dispatch.quiet {
5434 println!(
5435 "audit cache: {action} {}, skipped {} for {}",
5436 if *dry_run {
5437 report.found.saturating_sub(report.skipped)
5438 } else {
5439 report.removed
5440 },
5441 report.skipped,
5442 dispatch.root.display(),
5443 );
5444 }
5445 if report.skipped == 0 {
5446 ExitCode::SUCCESS
5447 } else {
5448 ExitCode::from(2)
5449 }
5450 }
5451 Err(error) => emit_error(
5452 &format!(
5453 "failed to remove audit caches for {}: {error}",
5454 dispatch.root.display()
5455 ),
5456 2,
5457 dispatch.output,
5458 ),
5459 }
5460 }
5461 AuditCacheCli::Prune {
5462 dry_run,
5463 max_age_days,
5464 } => audit_cache_prune::run_audit_cache_prune(&audit_cache_prune::AuditCachePruneOptions {
5465 root: dispatch.root,
5466 config_path: dispatch.cli.config.as_ref(),
5467 allow_remote_extends: dispatch.cli.allow_remote_extends,
5468 dry_run: *dry_run,
5469 max_age_days: *max_age_days,
5470 output: dispatch.output,
5471 json_style: dispatch.json_style,
5472 quiet: dispatch.quiet,
5473 }),
5474 }
5475}
5476
5477fn dispatch_flags_command(
5478 dispatch: &DispatchContext<'_>,
5479 top: Option<usize>,
5480 retirement: Option<flags::RetirementArgs>,
5481) -> ExitCode {
5482 let cli = dispatch.cli;
5483 let root = dispatch.root;
5484 let output = dispatch.output;
5485 let quiet = dispatch.quiet;
5486 let threads = dispatch.threads;
5487 let production = match resolve_production_modes(cli, root, output, false, false, false) {
5488 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::DeadCode),
5489 Err(code) => return code,
5490 };
5491 flags::run_flags(&flags::FlagsOptions {
5492 root,
5493 config_path: &cli.config,
5494 output,
5495 json_style: dispatch.json_style,
5496 no_cache: cli.no_cache,
5497 threads,
5498 quiet,
5499 allow_remote_extends: cli.allow_remote_extends,
5500 production,
5501 workspace: cli.workspace.as_deref(),
5502 changed_workspaces: cli.changed_workspaces.as_deref(),
5503 changed_since: cli.changed_since.as_deref(),
5504 explain: cli.explain,
5505 top,
5506 retirement,
5507 regression: dispatch.regression_opts(false),
5508 regression_flag: first_regression_flag(cli),
5509 })
5510}
5511
5512fn first_regression_flag(cli: &Cli) -> Option<&'static str> {
5514 [
5515 (cli.fail_on_regression, "--fail-on-regression"),
5516 (cli.regression_baseline.is_some(), "--regression-baseline"),
5517 (
5518 cli.save_regression_baseline.is_some(),
5519 "--save-regression-baseline",
5520 ),
5521 (cli.tolerance != "0", "--tolerance"),
5522 ]
5523 .into_iter()
5524 .find_map(|(used, flag)| used.then_some(flag))
5525}
5526
5527fn dispatch_suppressions_command(
5528 dispatch: &DispatchContext<'_>,
5529 file: &[std::path::PathBuf],
5530) -> ExitCode {
5531 let cli = dispatch.cli;
5532 let root = dispatch.root;
5533 let output = dispatch.output;
5534 let production = match resolve_production_modes(cli, root, output, false, false, false) {
5535 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::DeadCode),
5536 Err(code) => return code,
5537 };
5538 suppressions::run_suppressions(&suppressions::SuppressionsOptions {
5539 root,
5540 config_path: &cli.config,
5541 output,
5542 json_style: dispatch.json_style,
5543 no_cache: cli.no_cache,
5544 threads: dispatch.threads,
5545 quiet: dispatch.quiet,
5546 allow_remote_extends: cli.allow_remote_extends,
5547 production,
5548 workspace: cli.workspace.as_deref(),
5549 changed_workspaces: cli.changed_workspaces.as_deref(),
5550 changed_since: cli.changed_since.as_deref(),
5551 file,
5552 })
5553}
5554
5555fn dispatch_guard_command(dispatch: &DispatchContext<'_>, files: &[String]) -> ExitCode {
5556 guard::run_guard(&guard::GuardOptions {
5557 root: dispatch.root,
5558 config_path: &dispatch.cli.config,
5559 output: dispatch.output,
5560 json_style: dispatch.json_style,
5561 quiet: dispatch.quiet,
5562 allow_remote_extends: dispatch.cli.allow_remote_extends,
5563 files,
5564 })
5565}
5566
5567fn dispatch_rule_pack_command(dispatch: &DispatchContext<'_>, subcommand: RulePackCli) -> ExitCode {
5568 let ctx = rule_pack::RulePackContext {
5569 root: dispatch.root,
5570 config_path: &dispatch.cli.config,
5571 output: dispatch.output,
5572 json_style: dispatch.json_style,
5573 quiet: dispatch.quiet,
5574 no_cache: dispatch.cli.no_cache,
5575 threads: Some(dispatch.threads),
5576 allow_remote_extends: dispatch.cli.allow_remote_extends,
5577 };
5578 rule_pack::run(&map_rule_pack_subcommand(subcommand), &ctx)
5579}
5580
5581fn map_rule_pack_subcommand(subcommand: RulePackCli) -> rule_pack::RulePackSubcommand {
5582 match subcommand {
5583 RulePackCli::Init {
5584 name,
5585 template,
5586 dir,
5587 no_config,
5588 } => rule_pack::RulePackSubcommand::Init(rule_pack::InitArgs {
5589 name,
5590 template,
5591 dir,
5592 no_config,
5593 }),
5594 RulePackCli::List => rule_pack::RulePackSubcommand::List,
5595 RulePackCli::Test { pack } => {
5596 rule_pack::RulePackSubcommand::Test(rule_pack::TestArgs { pack })
5597 }
5598 RulePackCli::Schema => rule_pack::RulePackSubcommand::Schema,
5599 }
5600}
5601
5602fn map_license_subcommand(sub: LicenseCli) -> license::LicenseSubcommand {
5603 match sub {
5604 LicenseCli::Activate {
5605 jwt,
5606 from_file,
5607 stdin,
5608 trial,
5609 email,
5610 } => license::LicenseSubcommand::Activate(license::ActivateArgs {
5611 raw_jwt: jwt,
5612 from_file,
5613 from_stdin: stdin,
5614 trial,
5615 email,
5616 }),
5617 LicenseCli::Status => license::LicenseSubcommand::Status,
5618 LicenseCli::Refresh { api_key } => {
5619 license::LicenseSubcommand::Refresh(license::RefreshArgs { api_key })
5620 }
5621 LicenseCli::Deactivate => license::LicenseSubcommand::Deactivate,
5622 }
5623}
5624
5625fn map_telemetry_subcommand(sub: TelemetryCli) -> telemetry::TelemetryCommand {
5626 match sub {
5627 TelemetryCli::Status => telemetry::TelemetryCommand::Status,
5628 TelemetryCli::Enable => telemetry::TelemetryCommand::Enable,
5629 TelemetryCli::Disable => telemetry::TelemetryCommand::Disable,
5630 TelemetryCli::Inspect { example } => telemetry::TelemetryCommand::Inspect { example },
5631 }
5632}
5633
5634fn map_ci_subcommand(sub: CiCli) -> ci::CiCommand {
5635 match sub {
5636 command @ CiCli::PlanPrComment { .. } => map_ci_plan_pr_comment(command),
5637 command @ CiCli::PostPrComment { .. } => map_ci_post_pr_comment(command),
5638 command @ CiCli::PostReview { .. } => map_ci_post_review(command),
5639 command @ CiCli::PostCheckRun { .. } => map_ci_post_check_run(command),
5640 command @ CiCli::ReconcileReview { .. } => map_ci_reconcile_review(command),
5641 }
5642}
5643
5644fn map_ci_plan_pr_comment(command: CiCli) -> ci::CiCommand {
5645 let CiCli::PlanPrComment {
5646 body,
5647 marker_id,
5648 clean,
5649 existing_comment_id,
5650 existing_body,
5651 } = command
5652 else {
5653 unreachable!("ci plan-pr-comment mapper called with different variant");
5654 };
5655
5656 ci::CiCommand::PlanPrComment {
5657 body,
5658 marker_id,
5659 clean,
5660 existing_comment_id,
5661 existing_body,
5662 }
5663}
5664
5665fn map_ci_post_pr_comment(command: CiCli) -> ci::CiCommand {
5666 let CiCli::PostPrComment {
5667 provider,
5668 pr,
5669 mr,
5670 body,
5671 envelope,
5672 marker_id,
5673 clean,
5674 repo,
5675 project_id,
5676 api_url,
5677 dry_run,
5678 } = command
5679 else {
5680 unreachable!("ci post-pr-comment mapper called with different variant");
5681 };
5682
5683 ci::CiCommand::PostPrComment {
5684 provider: map_ci_provider(provider),
5685 target: pr.or(mr),
5686 body,
5687 envelope,
5688 marker_id,
5689 clean,
5690 repo,
5691 project_id,
5692 api_url,
5693 dry_run,
5694 }
5695}
5696
5697fn map_ci_post_review(command: CiCli) -> ci::CiCommand {
5698 let CiCli::PostReview {
5699 provider,
5700 pr,
5701 mr,
5702 envelope,
5703 repo,
5704 project_id,
5705 api_url,
5706 dry_run,
5707 } = command
5708 else {
5709 unreachable!("ci post-review mapper called with different variant");
5710 };
5711
5712 ci::CiCommand::PostReview {
5713 provider: map_ci_provider(provider),
5714 target: pr.or(mr),
5715 envelope,
5716 repo,
5717 project_id,
5718 api_url,
5719 dry_run,
5720 }
5721}
5722
5723fn map_ci_post_check_run(command: CiCli) -> ci::CiCommand {
5724 let CiCli::PostCheckRun {
5725 provider,
5726 decision,
5727 repo,
5728 head_sha,
5729 api_url,
5730 split_gates,
5731 dry_run,
5732 } = command
5733 else {
5734 unreachable!("ci post-check-run mapper called with different variant");
5735 };
5736
5737 ci::CiCommand::PostCheckRun {
5738 provider: map_ci_provider(provider),
5739 decision,
5740 repo,
5741 head_sha,
5742 api_url,
5743 split_gates,
5744 dry_run,
5745 }
5746}
5747
5748fn map_ci_reconcile_review(command: CiCli) -> ci::CiCommand {
5749 let CiCli::ReconcileReview {
5750 provider,
5751 pr,
5752 mr,
5753 envelope,
5754 repo,
5755 project_id,
5756 api_url,
5757 dry_run,
5758 } = command
5759 else {
5760 unreachable!("ci reconcile-review mapper called with different variant");
5761 };
5762
5763 ci::CiCommand::ReconcileReview {
5764 provider: map_ci_provider(provider),
5765 target: pr.or(mr),
5766 envelope,
5767 repo,
5768 project_id,
5769 api_url,
5770 dry_run,
5771 }
5772}
5773
5774fn map_ci_provider(provider: CiProviderArg) -> ci::CiProvider {
5775 match provider {
5776 CiProviderArg::Github => ci::CiProvider::Github,
5777 CiProviderArg::Gitlab => ci::CiProvider::Gitlab,
5778 }
5779}
5780
5781fn map_coverage_subcommand(
5782 sub: &CoverageCli,
5783 explain: bool,
5784 base: Option<&str>,
5785) -> coverage::CoverageSubcommand {
5786 match sub {
5787 CoverageCli::Setup {
5788 yes,
5789 non_interactive,
5790 json,
5791 } => map_coverage_setup(*yes, *non_interactive, *json, explain),
5792 CoverageCli::Analyze { .. } => map_coverage_analyze(sub),
5793 CoverageCli::UploadInventory { .. } => map_coverage_upload_inventory(sub),
5794 CoverageCli::UploadSourceMaps { .. } => map_coverage_upload_source_maps(sub),
5795 CoverageCli::UploadStaticFindings { .. } => map_coverage_upload_static_findings(sub),
5796 CoverageCli::ReviewPacket {
5797 api_key,
5798 api_endpoint,
5799 repo,
5800 project_id,
5801 coverage_period,
5802 commit_sha,
5803 files,
5804 functions,
5805 } => coverage::CoverageSubcommand::ReviewPacket(coverage::ReviewPacketArgs {
5806 api_key: api_key.clone(),
5807 api_endpoint: api_endpoint.clone(),
5808 repo: repo.clone(),
5809 project_id: project_id.clone(),
5810 coverage_period: *coverage_period,
5811 commit_sha: commit_sha.clone(),
5812 files: files.clone(),
5813 functions: functions.clone(),
5814 base: base.map(str::to_owned),
5815 }),
5816 CoverageCli::DeploymentChanges {
5817 api_key,
5818 api_endpoint,
5819 repo,
5820 sha,
5821 change,
5822 limit,
5823 cursor,
5824 } => coverage::CoverageSubcommand::DeploymentChanges(coverage::DeploymentChangesArgs {
5825 api_key: api_key.clone(),
5826 api_endpoint: api_endpoint.clone(),
5827 repo: repo.clone(),
5828 sha: sha.clone(),
5829 base: base.map(str::to_owned),
5830 change: change.clone(),
5831 limit: *limit,
5832 cursor: cursor.clone(),
5833 }),
5834 }
5835}
5836
5837fn map_coverage_setup(
5838 yes: bool,
5839 non_interactive: bool,
5840 json: bool,
5841 explain: bool,
5842) -> coverage::CoverageSubcommand {
5843 coverage::CoverageSubcommand::Setup(coverage::SetupArgs {
5844 yes,
5845 non_interactive: non_interactive || json,
5846 json,
5847 explain,
5848 })
5849}
5850
5851fn map_coverage_analyze(sub: &CoverageCli) -> coverage::CoverageSubcommand {
5852 let CoverageCli::Analyze {
5853 runtime_coverage,
5854 cloud,
5855 api_key,
5856 api_endpoint,
5857 repo,
5858 project_id,
5859 coverage_period,
5860 environment,
5861 commit_sha,
5862 production,
5863 min_invocations_hot,
5864 min_observation_volume,
5865 low_traffic_threshold,
5866 top,
5867 blast_radius,
5868 importance,
5869 debug_unmatched,
5870 } = sub
5871 else {
5872 unreachable!("coverage analyze mapper called with non-analyze variant");
5873 };
5874 coverage::CoverageSubcommand::Analyze(coverage::AnalyzeArgs {
5875 runtime_coverage: runtime_coverage.clone(),
5876 cloud: *cloud,
5877 api_key: api_key.clone(),
5878 api_endpoint: api_endpoint.clone(),
5879 repo: repo.clone(),
5880 project_id: project_id.clone(),
5881 coverage_period: *coverage_period,
5882 environment: environment.clone(),
5883 commit_sha: commit_sha.clone(),
5884 production: *production,
5885 min_invocations_hot: *min_invocations_hot,
5886 min_observation_volume: *min_observation_volume,
5887 low_traffic_threshold: *low_traffic_threshold,
5888 top: *top,
5889 blast_radius: *blast_radius,
5890 importance: *importance,
5891 debug_unmatched: *debug_unmatched,
5892 })
5893}
5894
5895fn map_coverage_upload_inventory(sub: &CoverageCli) -> coverage::CoverageSubcommand {
5896 let CoverageCli::UploadInventory {
5897 api_key,
5898 api_endpoint,
5899 project_id,
5900 git_sha,
5901 allow_dirty,
5902 exclude_paths,
5903 path_prefix,
5904 dry_run,
5905 with_callers,
5906 ignore_upload_errors,
5907 } = sub
5908 else {
5909 unreachable!("coverage inventory mapper called with non-inventory variant");
5910 };
5911 coverage::CoverageSubcommand::UploadInventory(coverage::UploadInventoryArgs {
5912 api_key: api_key.clone(),
5913 api_endpoint: api_endpoint.clone(),
5914 project_id: project_id.clone(),
5915 git_sha: git_sha.clone(),
5916 allow_dirty: *allow_dirty,
5917 exclude_paths: exclude_paths.clone(),
5918 path_prefix: path_prefix.clone(),
5919 dry_run: *dry_run,
5920 with_callers: *with_callers,
5921 ignore_upload_errors: *ignore_upload_errors,
5922 })
5923}
5924
5925fn map_coverage_upload_source_maps(sub: &CoverageCli) -> coverage::CoverageSubcommand {
5926 let CoverageCli::UploadSourceMaps {
5927 dir,
5928 include,
5929 exclude,
5930 repo,
5931 git_sha,
5932 endpoint,
5933 strip_path,
5934 dry_run,
5935 concurrency,
5936 fail_fast,
5937 } = sub
5938 else {
5939 unreachable!("coverage source-map mapper called with non-source-map variant");
5940 };
5941 coverage::CoverageSubcommand::UploadSourceMaps(coverage::UploadSourceMapsArgs {
5942 dir: dir.clone(),
5943 include: include.clone(),
5944 exclude: exclude.clone(),
5945 repo: repo.clone(),
5946 git_sha: git_sha.clone(),
5947 endpoint: endpoint.clone(),
5948 strip_path: *strip_path,
5949 dry_run: *dry_run,
5950 concurrency: *concurrency,
5951 fail_fast: *fail_fast,
5952 })
5953}
5954
5955fn map_coverage_upload_static_findings(sub: &CoverageCli) -> coverage::CoverageSubcommand {
5956 let CoverageCli::UploadStaticFindings {
5957 api_key,
5958 api_endpoint,
5959 project_id,
5960 git_sha,
5961 allow_dirty,
5962 dry_run,
5963 ignore_upload_errors,
5964 } = sub
5965 else {
5966 unreachable!("coverage static-findings mapper called with non-static variant");
5967 };
5968 coverage::CoverageSubcommand::UploadStaticFindings(coverage::UploadStaticFindingsArgs {
5969 api_key: api_key.clone(),
5970 api_endpoint: api_endpoint.clone(),
5971 project_id: project_id.clone(),
5972 git_sha: git_sha.clone(),
5973 allow_dirty: *allow_dirty,
5974 dry_run: *dry_run,
5975 ignore_upload_errors: *ignore_upload_errors,
5976 })
5977}
5978
5979struct CheckDispatchArgs {
5980 filters: IssueFilters,
5981 trace_opts: TraceOptions,
5982 include_dupes: bool,
5983 type_aware: Option<bool>,
5984 type_aware_project: Vec<std::path::PathBuf>,
5985 type_aware_require: Option<TypeAwareRequireArg>,
5986 top: Option<usize>,
5987 file: Vec<std::path::PathBuf>,
5988 scope: Option<std::path::PathBuf>,
5989 finding_ids: Option<fallow_engine::dead_code::FindingIdFilter>,
5990}
5991
5992#[derive(Clone)]
5993struct ListDispatchArgs {
5994 entry_points: bool,
5995 files: bool,
5996 plugins: bool,
5997 boundaries: bool,
5998 workspaces: bool,
5999 entry_weight: bool,
6000 scope: Option<std::path::PathBuf>,
6001}
6002
6003impl ListDispatchArgs {
6004 fn workspaces() -> Self {
6005 Self {
6006 entry_points: false,
6007 files: false,
6008 plugins: false,
6009 boundaries: false,
6010 workspaces: true,
6011 entry_weight: false,
6012 scope: None,
6013 }
6014 }
6015}
6016
6017fn dispatch_viz(
6018 dispatch: &DispatchContext<'_>,
6019 output_path: Option<&std::path::Path>,
6020 no_open: bool,
6021 format: viz::VizFormat,
6022) -> ExitCode {
6023 let cli = dispatch.cli;
6024 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
6025 Ok(production) => production,
6026 Err(code) => return code,
6027 };
6028 viz::run_viz(&viz::VizOptions {
6029 root: dispatch.root,
6030 config_path: &cli.config,
6031 no_cache: cli.no_cache,
6032 threads: dispatch.threads,
6033 quiet: dispatch.quiet,
6034 production,
6035 allow_remote_extends: cli.allow_remote_extends,
6036 output_path,
6037 no_open,
6038 format,
6039 })
6040}
6041
6042fn dispatch_watch(dispatch: &DispatchContext<'_>, no_clear: bool) -> ExitCode {
6043 let cli = dispatch.cli;
6044 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
6045 Ok(production) => production,
6046 Err(code) => return code,
6047 };
6048 watch::run_watch(&watch::WatchOptions {
6049 root: dispatch.root,
6050 config_path: &cli.config,
6051 output: dispatch.output,
6052 json_style: dispatch.json_style,
6053 no_cache: cli.no_cache,
6054 threads: dispatch.threads,
6055 quiet: dispatch.quiet,
6056 allow_remote_extends: cli.allow_remote_extends,
6057 production,
6058 clear_screen: !no_clear,
6059 explain: cli.explain,
6060 include_entry_exports: cli.include_entry_exports,
6061 type_aware: cli.type_aware_override(),
6062 type_aware_projects: &cli.type_aware_project,
6063 type_aware_require: cli.type_aware_require.map(Into::into),
6064 })
6065}
6066
6067struct FixDispatchArgs {
6068 dry_run: bool,
6069 yes: bool,
6070 no_create_config: bool,
6071 scope: Option<std::path::PathBuf>,
6072}
6073
6074fn dispatch_fix(dispatch: &DispatchContext<'_>, args: &FixDispatchArgs) -> ExitCode {
6075 let cli = dispatch.cli;
6076 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
6077 Ok(production) => production,
6078 Err(code) => return code,
6079 };
6080 fix::run_fix(&fix::FixOptions {
6081 root: dispatch.root,
6082 config_path: &cli.config,
6083 output: dispatch.output,
6084 json_style: dispatch.json_style,
6085 no_cache: cli.no_cache,
6086 threads: dispatch.threads,
6087 quiet: dispatch.quiet,
6088 emit_output: true,
6089 allow_remote_extends: cli.allow_remote_extends,
6090 dry_run: args.dry_run,
6091 yes: args.yes,
6092 production,
6093 no_create_config: args.no_create_config,
6094 type_aware: cli.type_aware_override(),
6095 type_aware_projects: &cli.type_aware_project,
6096 type_aware_require: cli.type_aware_require.map(Into::into),
6097 scope: args.scope.clone(),
6098 })
6099}
6100
6101fn dispatch_list(dispatch: &DispatchContext<'_>, args: &ListDispatchArgs) -> ExitCode {
6102 let cli = dispatch.cli;
6103 let (save_regression_file, save_to_config) = regression_save_targets(cli);
6104 let entry_weight_gate = if args.entry_weight {
6107 let tolerance = match regression::Tolerance::parse(&cli.tolerance) {
6108 Ok(tolerance) => tolerance,
6109 Err(message) => {
6110 return emit_error(
6111 &format!("invalid --tolerance: {message}"),
6112 2,
6113 dispatch.output,
6114 );
6115 }
6116 };
6117 Some(regression::EntryWeightGate {
6118 fail_on_regression: cli.fail_on_regression,
6119 tolerance,
6120 baseline_file: cli.regression_baseline.as_deref(),
6121 save_file: save_regression_file.as_deref(),
6122 save_to_config,
6123 })
6124 } else {
6125 None
6126 };
6127 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
6128 Ok(production) => production,
6129 Err(code) => return code,
6130 };
6131 list::run_list(&ListOptions {
6132 root: dispatch.root,
6133 config_path: &cli.config,
6134 output: dispatch.output,
6135 json_style: dispatch.json_style,
6136 threads: dispatch.threads,
6137 no_cache: cli.no_cache,
6138 entry_points: args.entry_points,
6139 files: args.files,
6140 plugins: args.plugins,
6141 boundaries: args.boundaries,
6142 workspaces: args.workspaces,
6143 entry_weight: args.entry_weight,
6144 entry_weight_gate,
6145 production,
6146 allow_remote_extends: cli.allow_remote_extends,
6147 scope: args.scope.clone(),
6148 })
6149}
6150
6151fn dispatch_check(dispatch: &DispatchContext<'_>, args: &CheckDispatchArgs) -> ExitCode {
6152 let cli = dispatch.cli;
6153 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
6154 Ok(production) => production,
6155 Err(code) => return code,
6156 };
6157 if let Some(code) = validate_type_aware_check_options(dispatch, args) {
6158 return code;
6159 }
6160 let growth_targets = baseline_growth::targets(&[(
6161 cli.baseline.as_deref(),
6162 fallow_engine::baseline::BaselineKind::DeadCode,
6163 )]);
6164 baseline_growth::run_gated(
6165 &dispatch.growth_ctx(baseline_growth::GrowthOwner::DeadCode),
6166 &growth_targets,
6167 || dispatch_check_run(dispatch, args, production),
6168 )
6169}
6170
6171fn dispatch_check_run(
6172 dispatch: &DispatchContext<'_>,
6173 args: &CheckDispatchArgs,
6174 production: bool,
6175) -> ExitCode {
6176 let cli = dispatch.cli;
6177 let (output, quiet, fail_on_issues) =
6178 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
6179 check::run_check(&CheckOptions {
6180 root: dispatch.root,
6181 config_path: &cli.config,
6182 output,
6183 json_style: dispatch.json_style,
6184 no_cache: cli.no_cache,
6185 threads: dispatch.threads,
6186 quiet,
6187 allow_remote_extends: cli.allow_remote_extends,
6188 fail_on_issues,
6189 filters: &args.filters,
6190 changed_since: cli.changed_since.as_deref(),
6191 change_scope_owner: fallow_engine::change_scope::ChangeScopeOwner::Run,
6192 no_package_baselines: cli.no_package_baselines,
6193 diff_index: None,
6194 use_shared_diff_index: true,
6195 baseline: cli.baseline.as_deref(),
6196 baseline_flag: "--baseline",
6197 save_baseline: cli.save_baseline.as_deref(),
6198 fail_on_stale_baseline: cli.fail_on_stale_baseline,
6199 sarif_file: cli.sarif_file.as_deref(),
6200 production,
6201 production_override: Some(production),
6202 workspace: cli.workspace.as_deref(),
6203 changed_workspaces: cli.changed_workspaces.as_deref(),
6204 group_by: cli.group_by,
6205 include_dupes: args.include_dupes,
6206 type_aware: args.type_aware,
6207 type_aware_config_override: None,
6208 type_aware_projects: &args.type_aware_project,
6209 type_aware_require: args.type_aware_require.map(Into::into),
6210 trace_opts: &args.trace_opts,
6211 explain: cli.explain,
6212 top: args.top,
6213 file: &args.file,
6214 scope: args.scope.clone(),
6215 finding_ids: args.finding_ids.as_ref(),
6216 include_entry_exports: cli.include_entry_exports,
6217 fail_on_parse_error: cli.fail_on_parse_error,
6218 summary: cli.summary,
6219 regression_opts: dispatch.regression_opts(
6220 cli.changed_since.is_some()
6221 || cli.workspace.is_some()
6222 || cli.changed_workspaces.is_some()
6223 || !args.file.is_empty()
6224 || args.scope.is_some(),
6225 ),
6226 retain_modules_for_health: false,
6227 defer_performance: true,
6228 analysis_snapshot: fallow_config::AnalysisSnapshot::Current,
6229 explain_skipped: cli.explain_skipped,
6230 })
6231}
6232
6233fn validate_type_aware_check_options(
6234 dispatch: &DispatchContext<'_>,
6235 args: &CheckDispatchArgs,
6236) -> Option<ExitCode> {
6237 let output = dispatch.output;
6238 if !args.type_aware_project.is_empty() && args.type_aware != Some(true) {
6239 return Some(emit_error(
6240 "--type-aware-project requires --type-aware",
6241 2,
6242 output,
6243 ));
6244 }
6245 if args.type_aware_require.is_some() && args.type_aware != Some(true) {
6246 return Some(emit_error(
6247 "--type-aware-require requires --type-aware",
6248 2,
6249 output,
6250 ));
6251 }
6252 if args.trace_opts.symbol_impact.is_some() && args.type_aware != Some(true) {
6253 return Some(emit_error(
6254 "--symbol-impact requires --type-aware",
6255 2,
6256 output,
6257 ));
6258 }
6259 let focused_output = args.trace_opts.trace_export.is_some()
6260 || args.trace_opts.trace_file.is_some()
6261 || args.trace_opts.trace_dependency.is_some()
6262 || args.trace_opts.impact_closure.is_some()
6263 || args.trace_opts.symbol_impact.is_some();
6264 if focused_output
6265 && !matches!(
6266 output,
6267 fallow_config::OutputFormat::Human | fallow_config::OutputFormat::Json
6268 )
6269 {
6270 return Some(emit_error(
6271 "focused trace and impact queries support human and JSON output",
6272 2,
6273 output,
6274 ));
6275 }
6276 if args.type_aware == Some(true)
6277 && !matches!(
6278 output,
6279 fallow_config::OutputFormat::Human
6280 | fallow_config::OutputFormat::Json
6281 | fallow_config::OutputFormat::Sarif
6282 | fallow_config::OutputFormat::Compact
6283 | fallow_config::OutputFormat::Markdown
6284 | fallow_config::OutputFormat::CodeClimate
6285 | fallow_config::OutputFormat::PrCommentGithub
6286 | fallow_config::OutputFormat::PrCommentGitlab
6287 | fallow_config::OutputFormat::ReviewGithub
6288 | fallow_config::OutputFormat::ReviewGitlab
6289 )
6290 {
6291 return Some(emit_error(
6292 "--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",
6293 2,
6294 output,
6295 ));
6296 }
6297 None
6298}
6299
6300const fn resolve_negatable_flag(opt_in: bool, opt_out: bool) -> Option<bool> {
6308 if opt_out {
6309 Some(false)
6310 } else if opt_in {
6311 Some(true)
6312 } else {
6313 None
6314 }
6315}
6316
6317struct DupesDispatchArgs {
6318 overrides: DupesOverrides,
6319 top: Option<usize>,
6320 no_fragments: bool,
6321 trace: Option<String>,
6322 scope: Option<std::path::PathBuf>,
6323}
6324
6325fn dispatch_dupes(dispatch: &DispatchContext<'_>, args: &DupesDispatchArgs) -> ExitCode {
6326 let cli = dispatch.cli;
6327 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::Dupes) {
6328 Ok(production) => production,
6329 Err(code) => return code,
6330 };
6331 let growth_targets = baseline_growth::targets(&[(
6332 cli.baseline.as_deref(),
6333 fallow_engine::baseline::BaselineKind::Dupes,
6334 )]);
6335 baseline_growth::run_gated(
6336 &dispatch.growth_ctx(baseline_growth::GrowthOwner::Dupes),
6337 &growth_targets,
6338 || dispatch_dupes_run(dispatch, args, production),
6339 )
6340}
6341
6342fn dispatch_dupes_run(
6343 dispatch: &DispatchContext<'_>,
6344 args: &DupesDispatchArgs,
6345 production: bool,
6346) -> ExitCode {
6347 let cli = dispatch.cli;
6348 let (output, quiet) = (dispatch.output, dispatch.quiet);
6349 dupes::run_dupes(&DupesOptions {
6350 root: dispatch.root,
6351 config_path: &cli.config,
6352 output,
6353 json_style: dispatch.json_style,
6354 no_cache: cli.no_cache,
6355 threads: dispatch.threads,
6356 quiet,
6357 allow_remote_extends: cli.allow_remote_extends,
6358 overrides: args.overrides,
6359 top: args.top,
6360 baseline_path: cli.baseline.as_deref(),
6361 baseline_flag: "--baseline",
6362 save_baseline_path: cli.save_baseline.as_deref(),
6363 fail_on_stale_baseline: cli.fail_on_stale_baseline,
6364 fail_on_issues: dispatch.fail_on_issues,
6365 production,
6366 production_override: Some(production),
6367 trace: args.trace.as_deref(),
6368 changed_since: cli.changed_since.as_deref(),
6369 diff_index: None,
6370 use_shared_diff_index: true,
6371 changed_files: None,
6372 change_scope_owner: fallow_engine::change_scope::ChangeScopeOwner::Run,
6373 no_package_baselines: cli.no_package_baselines,
6374 workspace: cli.workspace.as_deref(),
6375 changed_workspaces: cli.changed_workspaces.as_deref(),
6376 explain: cli.explain,
6377 explain_skipped: cli.explain_skipped,
6378 summary: cli.summary,
6379 group_by: cli.group_by,
6380 performance: cli.performance,
6381 include_fragments: !args.no_fragments,
6382 retain_unfiltered_report: false,
6383 scope: args.scope.clone(),
6384 })
6385}
6386
6387struct AuditDispatchArgs {
6388 production_dead_code: bool,
6389 production_health: bool,
6390 production_dupes: bool,
6391 dead_code_baseline: Option<PathBuf>,
6392 health_baseline: Option<PathBuf>,
6393 dupes_baseline: Option<PathBuf>,
6394 max_crap: Option<f64>,
6395 coverage: Option<PathBuf>,
6396 coverage_root: Option<PathBuf>,
6397 no_css: bool,
6398 css_deep: bool,
6399 no_css_deep: bool,
6400 gate: Option<AuditGateArg>,
6401 runtime_coverage: Option<PathBuf>,
6402 min_invocations_hot: u64,
6403 gate_marker: Option<String>,
6404 brief: bool,
6405 max_decisions: usize,
6406 walkthrough_guide: bool,
6408 walkthrough_file: Option<PathBuf>,
6411 walkthrough: bool,
6413 mark_viewed: Vec<PathBuf>,
6415 show_cleared: bool,
6417 show_deprioritized: bool,
6419 scope: Option<PathBuf>,
6420}
6421
6422struct ResolvedAuditInputs {
6423 audit_cfg: fallow_config::AuditConfig,
6424 cache_dir: PathBuf,
6425 production: ProductionModes,
6426 dead_code_baseline: Option<PathBuf>,
6427 health_baseline: Option<PathBuf>,
6428 dupes_baseline: Option<PathBuf>,
6429 coverage: Option<PathBuf>,
6433 coverage_root: Option<PathBuf>,
6434}
6435
6436fn dispatch_audit(dispatch: &DispatchContext<'_>, args: &AuditDispatchArgs) -> ExitCode {
6437 let cli = dispatch.cli;
6438 let output = dispatch.output;
6439
6440 if cli.baseline.is_some() || cli.save_baseline.is_some() {
6441 return emit_error(
6442 "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>`)",
6443 2,
6444 output,
6445 );
6446 }
6447
6448 let inputs = match resolve_audit_inputs(dispatch, args) {
6449 Ok(inputs) => inputs,
6450 Err(code) => return code,
6451 };
6452
6453 if cli.fail_on_baseline_growth && audit_renders_brief(args) {
6456 baseline_growth::note_stood_down(
6457 dispatch.growth_flags(),
6458 "the review brief never fails a run",
6459 );
6460 return run_resolved_audit(dispatch, args, &inputs);
6461 }
6462 let growth_targets = baseline_growth::targets(&[
6463 (
6464 inputs.dead_code_baseline.as_deref(),
6465 fallow_engine::baseline::BaselineKind::DeadCode,
6466 ),
6467 (
6468 inputs.health_baseline.as_deref(),
6469 fallow_engine::baseline::BaselineKind::Health,
6470 ),
6471 (
6472 inputs.dupes_baseline.as_deref(),
6473 fallow_engine::baseline::BaselineKind::Dupes,
6474 ),
6475 ]);
6476 baseline_growth::run_gated(
6477 &dispatch.growth_ctx(baseline_growth::GrowthOwner::Audit),
6478 &growth_targets,
6479 || run_resolved_audit(dispatch, args, &inputs),
6480 )
6481}
6482
6483const fn audit_renders_brief(args: &AuditDispatchArgs) -> bool {
6484 args.brief || args.walkthrough_guide || args.walkthrough || args.walkthrough_file.is_some()
6485}
6486
6487fn resolve_audit_inputs(
6488 dispatch: &DispatchContext<'_>,
6489 args: &AuditDispatchArgs,
6490) -> Result<ResolvedAuditInputs, ExitCode> {
6491 let cli = dispatch.cli;
6492 let root = dispatch.root;
6493 let output = dispatch.output;
6494 let config = load_config(
6495 root,
6496 &cli.config,
6497 LoadConfigArgs {
6498 output,
6499 no_cache: cli.no_cache,
6500 threads: dispatch.threads,
6501 production: cli.production,
6502 quiet: dispatch.quiet,
6503 allow_remote_extends: cli.allow_remote_extends,
6504 },
6505 )?;
6506 let cache_dir = config.cache_dir.clone();
6507 let audit_cfg = config.audit;
6508 let production = resolve_production_modes(
6509 cli,
6510 root,
6511 output,
6512 args.production_dead_code,
6513 args.production_health,
6514 args.production_dupes,
6515 )?;
6516 let resolved_dead_code_baseline = resolve_audit_baseline_path(
6517 root,
6518 args.dead_code_baseline.as_deref(),
6519 audit_cfg.dead_code_baseline.as_deref(),
6520 );
6521 let resolved_health_baseline = resolve_audit_baseline_path(
6522 root,
6523 args.health_baseline.as_deref(),
6524 audit_cfg.health_baseline.as_deref(),
6525 );
6526 let resolved_dupes_baseline = resolve_audit_baseline_path(
6527 root,
6528 args.dupes_baseline.as_deref(),
6529 audit_cfg.dupes_baseline.as_deref(),
6530 );
6531 let coverage_inputs = resolve_coverage_inputs(
6532 args.coverage.as_deref(),
6533 args.coverage_root.as_deref(),
6534 output,
6535 || Ok(config.health),
6536 )?;
6537
6538 Ok(ResolvedAuditInputs {
6539 audit_cfg,
6540 cache_dir,
6541 production,
6542 dead_code_baseline: resolved_dead_code_baseline,
6543 health_baseline: resolved_health_baseline,
6544 dupes_baseline: resolved_dupes_baseline,
6545 coverage: coverage_inputs.coverage,
6546 coverage_root: coverage_inputs.coverage_root,
6547 })
6548}
6549
6550fn audit_css_enabled(config: &fallow_config::AuditConfig, args: &AuditDispatchArgs) -> bool {
6551 !args.no_css && config.css.unwrap_or(true)
6552}
6553
6554fn audit_css_deep_enabled(config: &fallow_config::AuditConfig, args: &AuditDispatchArgs) -> bool {
6555 audit_css_enabled(config, args)
6556 && !args.no_css_deep
6557 && (args.css_deep || config.css_deep.unwrap_or(true))
6558}
6559
6560fn run_resolved_audit(
6561 dispatch: &DispatchContext<'_>,
6562 args: &AuditDispatchArgs,
6563 inputs: &ResolvedAuditInputs,
6564) -> ExitCode {
6565 let cli = dispatch.cli;
6566 audit::run_audit_with_type_aware(
6567 &audit::AuditOptions {
6568 root: dispatch.root,
6569 config_path: &cli.config,
6570 cache_dir: &inputs.cache_dir,
6571 output: dispatch.output,
6572 json_style: dispatch.json_style,
6573 no_cache: cli.no_cache,
6574 threads: dispatch.threads,
6575 quiet: dispatch.quiet,
6576 allow_remote_extends: cli.allow_remote_extends,
6577 changed_since: cli.changed_since.as_deref(),
6578 production: cli.production,
6579 production_dead_code: Some(inputs.production.dead_code),
6580 production_health: Some(inputs.production.health),
6581 production_dupes: Some(inputs.production.dupes),
6582 workspace: cli.workspace.as_deref(),
6583 changed_workspaces: cli.changed_workspaces.as_deref(),
6584 explain: cli.explain,
6585 explain_skipped: cli.explain_skipped,
6586 performance: cli.performance,
6587 group_by: cli.group_by,
6588 dead_code_baseline: inputs.dead_code_baseline.as_deref(),
6589 health_baseline: inputs.health_baseline.as_deref(),
6590 dupes_baseline: inputs.dupes_baseline.as_deref(),
6591 health_baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
6592 fail_on_stale_baseline: cli.fail_on_stale_baseline,
6593 max_crap: args.max_crap,
6594 coverage: inputs.coverage.as_deref(),
6595 coverage_root: inputs.coverage_root.as_deref(),
6596 gate: args.gate.map_or(inputs.audit_cfg.gate, Into::into),
6597 include_entry_exports: cli.include_entry_exports,
6598 fail_on_parse_error: cli.fail_on_parse_error,
6599 css: audit_css_enabled(&inputs.audit_cfg, args),
6603 css_deep: audit_css_deep_enabled(&inputs.audit_cfg, args),
6604 runtime_coverage: args.runtime_coverage.as_deref(),
6605 min_invocations_hot: args.min_invocations_hot,
6606 brief: args.brief,
6607 max_decisions: args.max_decisions,
6608 walkthrough_guide: args.walkthrough_guide,
6609 walkthrough: args.walkthrough,
6610 mark_viewed: &args.mark_viewed,
6611 show_cleared: args.show_cleared,
6612 walkthrough_file: args.walkthrough_file.as_deref(),
6613 show_deprioritized: args.show_deprioritized,
6614 scope: args.scope.clone(),
6615 },
6616 args.gate_marker.as_deref(),
6617 audit::AuditTypeAwareOptions {
6618 enabled: cli.type_aware_override(),
6619 config_default: inputs.audit_cfg.type_aware,
6620 projects: &cli.type_aware_project,
6621 require: cli.type_aware_require.map(Into::into),
6622 },
6623 )
6624}
6625
6626fn dispatch_decision_surface(dispatch: &DispatchContext<'_>, max_decisions: usize) -> ExitCode {
6630 let args = decision_surface_audit_args(max_decisions);
6631 let inputs = match resolve_audit_inputs(dispatch, &args) {
6632 Ok(inputs) => inputs,
6633 Err(code) => return code,
6634 };
6635 if dispatch.cli.fail_on_stale_baseline {
6639 for path in [
6640 &inputs.dead_code_baseline,
6641 &inputs.health_baseline,
6642 &inputs.dupes_baseline,
6643 ] {
6644 baseline_gate::note_stood_down(
6645 path.as_deref(),
6646 true,
6647 "decision-surface renders a brief without exit gates",
6648 );
6649 }
6650 }
6651 audit::run_decision_surface(&decision_surface_audit_options(
6652 dispatch,
6653 &inputs,
6654 max_decisions,
6655 ))
6656}
6657
6658fn decision_surface_audit_args(max_decisions: usize) -> AuditDispatchArgs {
6659 AuditDispatchArgs {
6660 production_dead_code: false,
6661 production_health: false,
6662 production_dupes: false,
6663 dead_code_baseline: None,
6664 health_baseline: None,
6665 dupes_baseline: None,
6666 max_crap: None,
6667 coverage: None,
6668 coverage_root: None,
6669 no_css: true,
6670 css_deep: false,
6671 no_css_deep: false,
6672 gate: None,
6673 runtime_coverage: None,
6674 min_invocations_hot: 0,
6675 gate_marker: None,
6676 brief: true,
6677 max_decisions,
6678 walkthrough_guide: false,
6679 walkthrough_file: None,
6680 walkthrough: false,
6681 mark_viewed: Vec::new(),
6682 show_cleared: false,
6683 show_deprioritized: false,
6684 scope: None,
6685 }
6686}
6687
6688fn decision_surface_audit_options<'a>(
6689 dispatch: &'a DispatchContext<'a>,
6690 inputs: &'a ResolvedAuditInputs,
6691 max_decisions: usize,
6692) -> audit::AuditOptions<'a> {
6693 let cli = dispatch.cli;
6694 audit::AuditOptions {
6695 root: dispatch.root,
6696 config_path: &cli.config,
6697 cache_dir: &inputs.cache_dir,
6698 output: dispatch.output,
6699 json_style: dispatch.json_style,
6700 no_cache: cli.no_cache,
6701 threads: dispatch.threads,
6702 quiet: dispatch.quiet,
6703 allow_remote_extends: cli.allow_remote_extends,
6704 changed_since: cli.changed_since.as_deref(),
6705 production: cli.production,
6706 production_dead_code: Some(inputs.production.dead_code),
6707 production_health: Some(inputs.production.health),
6708 production_dupes: Some(inputs.production.dupes),
6709 workspace: cli.workspace.as_deref(),
6710 changed_workspaces: cli.changed_workspaces.as_deref(),
6711 explain: cli.explain,
6712 explain_skipped: cli.explain_skipped,
6713 performance: cli.performance,
6714 group_by: cli.group_by,
6715 dead_code_baseline: inputs.dead_code_baseline.as_deref(),
6716 health_baseline: inputs.health_baseline.as_deref(),
6717 dupes_baseline: inputs.dupes_baseline.as_deref(),
6718 health_baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
6719 fail_on_stale_baseline: false,
6723 max_crap: None,
6724 coverage: None,
6725 coverage_root: None,
6726 gate: inputs.audit_cfg.gate,
6727 include_entry_exports: cli.include_entry_exports,
6728 fail_on_parse_error: false,
6729 css: false,
6731 css_deep: false,
6732 runtime_coverage: None,
6733 min_invocations_hot: 0,
6734 brief: true,
6735 max_decisions,
6736 walkthrough_guide: false,
6737 walkthrough: false,
6738 mark_viewed: &[],
6739 show_cleared: false,
6740 walkthrough_file: None,
6741 show_deprioritized: false,
6742 scope: None,
6743 }
6744}
6745
6746struct HealthDispatchArgs<'a> {
6747 max_cyclomatic: Option<u16>,
6748 max_cognitive: Option<u16>,
6749 max_crap: Option<f64>,
6750 top: Option<usize>,
6751 sort: health::SortBy,
6752 complexity: bool,
6753 complexity_breakdown: bool,
6754 file_scores: bool,
6755 coverage_gaps: bool,
6756 hotspots: bool,
6757 ownership: bool,
6758 ownership_emails: Option<fallow_config::EmailMode>,
6759 targets: bool,
6760 type_coupling: bool,
6761 css: bool,
6762 effort: Option<EffortFilter>,
6763 score: bool,
6764 min_score: Option<f64>,
6765 min_severity: Option<fallow_output::FindingSeverity>,
6766 report_only: bool,
6767 fail_on_stale_baseline: bool,
6768 fail_on_parse_error: bool,
6769 since: Option<&'a str>,
6770 min_commits: Option<u32>,
6771 save_snapshot: Option<&'a Option<String>>,
6772 trend: bool,
6773 trend_from: Option<&'a std::path::Path>,
6774 coverage: Option<&'a std::path::Path>,
6775 coverage_root: Option<&'a std::path::Path>,
6776 runtime_coverage: Option<&'a std::path::Path>,
6777 min_invocations_hot: u64,
6778 min_observation_volume: Option<u32>,
6779 low_traffic_threshold: Option<f64>,
6780 scope: Option<std::path::PathBuf>,
6781}
6782
6783type ResolvedHealthCoverageInputs = fallow_api::CoverageInputs;
6784
6785fn resolve_coverage_inputs(
6796 cli_coverage: Option<&std::path::Path>,
6797 cli_coverage_root: Option<&std::path::Path>,
6798 output: fallow_config::OutputFormat,
6799 config_health: impl FnOnce() -> Result<fallow_config::HealthConfig, ExitCode>,
6800) -> Result<ResolvedHealthCoverageInputs, ExitCode> {
6801 let explicit = fallow_api::CoverageInputs {
6802 coverage: cli_coverage.map(std::path::Path::to_path_buf),
6803 coverage_root: cli_coverage_root.map(std::path::Path::to_path_buf),
6804 };
6805 let env = fallow_api::CoverageInputs {
6806 coverage: path_from_env("FALLOW_COVERAGE"),
6807 coverage_root: path_from_env("FALLOW_COVERAGE_ROOT"),
6808 };
6809 let config_health = if fallow_api::CoverageInputs::needs_config_layer(&explicit, &env) {
6810 Some(config_health()?)
6811 } else {
6812 None
6813 };
6814
6815 fallow_api::resolve_coverage_inputs(explicit, env, config_health.as_ref())
6816 .map_err(|err| emit_error(&err.to_string(), 2, output))
6817}
6818
6819fn resolve_health_coverage_inputs(
6822 dispatch: &DispatchContext<'_>,
6823 cli_coverage: Option<&std::path::Path>,
6824 cli_coverage_root: Option<&std::path::Path>,
6825) -> Result<ResolvedHealthCoverageInputs, ExitCode> {
6826 resolve_coverage_inputs(cli_coverage, cli_coverage_root, dispatch.output, || {
6827 Ok(load_config(
6828 dispatch.root,
6829 &dispatch.cli.config,
6830 LoadConfigArgs {
6831 output: dispatch.output,
6832 no_cache: dispatch.cli.no_cache,
6833 threads: dispatch.threads,
6834 production: dispatch.cli.production,
6835 quiet: dispatch.quiet,
6836 allow_remote_extends: dispatch.cli.allow_remote_extends,
6837 },
6838 )?
6839 .health)
6840 })
6841}
6842
6843fn path_from_env(name: &str) -> Option<PathBuf> {
6844 std::env::var_os(name)
6845 .filter(|value| !value.is_empty())
6846 .map(PathBuf::from)
6847}
6848
6849fn validate_health_report_only_gate(
6850 report_only: bool,
6851 min_score: Option<f64>,
6852 min_severity: Option<fallow_output::FindingSeverity>,
6853 output: fallow_config::OutputFormat,
6854) -> Result<(), ExitCode> {
6855 if report_only && (min_score.is_some() || min_severity.is_some()) {
6856 return Err(emit_error(
6857 "--report-only cannot be combined with --min-score or --min-severity. \
6858 --report-only always exits 0; drop it to gate on score/severity, or \
6859 drop the gate flags to stay advisory.",
6860 2,
6861 output,
6862 ));
6863 }
6864
6865 Ok(())
6866}
6867
6868fn resolve_runtime_coverage_options(
6869 runtime_coverage: Option<&std::path::Path>,
6870 min_invocations_hot: u64,
6871 min_observation_volume: Option<u32>,
6872 low_traffic_threshold: Option<f64>,
6873 output: fallow_config::OutputFormat,
6874) -> Result<Option<fallow_engine::health::RuntimeCoverageOptions>, ExitCode> {
6875 let Some(path) = runtime_coverage else {
6876 return Ok(None);
6877 };
6878
6879 health::coverage::prepare_options(
6880 path,
6881 min_invocations_hot,
6882 min_observation_volume,
6883 low_traffic_threshold,
6884 output,
6885 )
6886 .map(Some)
6887}
6888
6889fn dispatch_health(dispatch: &DispatchContext<'_>, args: &HealthDispatchArgs<'_>) -> ExitCode {
6890 let cli = dispatch.cli;
6891 let root = dispatch.root;
6892 let (output, _quiet, _fail_on_issues) =
6893 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
6894 if let Err(code) = validate_health_report_only_gate(
6895 args.report_only,
6896 args.min_score,
6897 args.min_severity,
6898 output,
6899 ) {
6900 return code;
6901 }
6902 let runtime_coverage = match resolve_runtime_coverage_options(
6903 args.runtime_coverage,
6904 args.min_invocations_hot,
6905 args.min_observation_volume,
6906 args.low_traffic_threshold,
6907 output,
6908 ) {
6909 Ok(options) => options,
6910 Err(code) => return code,
6911 };
6912 let production = match resolve_production_modes(cli, root, output, false, false, false) {
6913 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::Health),
6914 Err(code) => return code,
6915 };
6916 let coverage_inputs =
6917 match resolve_health_coverage_inputs(dispatch, args.coverage, args.coverage_root) {
6918 Ok(inputs) => inputs,
6919 Err(code) => return code,
6920 };
6921 let run = derive_health_dispatch_run(args, output, &coverage_inputs, runtime_coverage);
6922 let resolved = ResolvedHealthDispatch { run, production };
6923 if args.report_only && cli.fail_on_baseline_growth {
6926 baseline_growth::note_stood_down(
6927 dispatch.growth_flags(),
6928 "health --report-only never fails a run",
6929 );
6930 return run_health_dispatch(dispatch, args, resolved);
6931 }
6932 let growth_targets = baseline_growth::targets(&[(
6933 cli.baseline.as_deref(),
6934 fallow_engine::baseline::BaselineKind::Health,
6935 )]);
6936 baseline_growth::run_gated(
6937 &dispatch.growth_ctx(baseline_growth::GrowthOwner::Health),
6938 &growth_targets,
6939 || run_health_dispatch(dispatch, args, resolved),
6940 )
6941}
6942
6943fn derive_health_dispatch_run<'a>(
6944 args: &'a HealthDispatchArgs<'a>,
6945 output: fallow_config::OutputFormat,
6946 coverage_inputs: &'a ResolvedHealthCoverageInputs,
6947 runtime_coverage: Option<fallow_engine::health::RuntimeCoverageOptions>,
6948) -> fallow_engine::health::HealthRunOptions<'a> {
6949 let mut run = fallow_engine::health::derive_health_run_options(
6950 fallow_engine::health::HealthRunOptionsInput {
6951 output,
6952 thresholds: health_threshold_overrides(args),
6953 top: args.top,
6954 sort: args.sort.clone().into(),
6955 complexity: args.complexity,
6956 file_scores: args.file_scores,
6957 coverage_gaps: args.coverage_gaps,
6958 hotspots: args.hotspots,
6959 ownership: args.ownership,
6960 ownership_emails: args.ownership_emails,
6961 targets: args.targets,
6962 css: args.css,
6963 effort: args.effort.map(EffortFilter::to_estimate),
6964 score: args.score,
6965 gates: health_gate_options(args),
6966 snapshot_requested: args.save_snapshot.is_some(),
6967 trend: args.trend,
6968 since: args.since,
6969 min_commits: args.min_commits,
6970 coverage_inputs: health_coverage_inputs(coverage_inputs),
6971 runtime_coverage,
6972 },
6973 );
6974 if args.type_coupling && !run.sections.any_section {
6975 run.sections = fallow_engine::health::DerivedHealthSections {
6976 any_section: true,
6977 complexity: false,
6978 file_scores: false,
6979 coverage_gaps: false,
6980 hotspots: false,
6981 targets: false,
6982 css: false,
6983 score: false,
6984 force_full: false,
6985 score_only_output: false,
6986 };
6987 }
6988 run
6989}
6990
6991fn health_threshold_overrides(
6992 args: &HealthDispatchArgs<'_>,
6993) -> fallow_engine::health::HealthThresholdOverrides {
6994 fallow_engine::health::HealthThresholdOverrides {
6995 max_cyclomatic: args.max_cyclomatic,
6996 max_cognitive: args.max_cognitive,
6997 max_crap: args.max_crap,
6998 }
6999}
7000
7001fn health_gate_options(args: &HealthDispatchArgs<'_>) -> fallow_engine::health::HealthGateOptions {
7002 fallow_engine::health::HealthGateOptions {
7003 min_score: args.min_score,
7004 min_severity: args.min_severity,
7005 report_only: args.report_only,
7006 fail_on_stale_baseline: args.fail_on_stale_baseline,
7007 fail_on_parse_error: args.fail_on_parse_error,
7008 fail_on_issues: false,
7009 }
7010}
7011
7012fn health_coverage_inputs(
7013 coverage_inputs: &ResolvedHealthCoverageInputs,
7014) -> fallow_engine::health::HealthCoverageInputs<'_> {
7015 fallow_engine::health::HealthCoverageInputs {
7016 coverage: coverage_inputs.coverage.as_deref(),
7017 coverage_root: coverage_inputs.coverage_root.as_deref(),
7018 coverage_relocated: false,
7019 }
7020}
7021
7022struct ResolvedHealthDispatch<'a> {
7026 run: fallow_engine::health::HealthRunOptions<'a>,
7027 production: bool,
7028}
7029
7030fn run_health_dispatch(
7033 dispatch: &DispatchContext<'_>,
7034 args: &HealthDispatchArgs<'_>,
7035 resolved: ResolvedHealthDispatch<'_>,
7036) -> ExitCode {
7037 let cli = dispatch.cli;
7038 let (output, quiet, fail_on_issues) =
7039 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
7040 let mut run = resolved.run;
7041 run.gates.fail_on_issues = fail_on_issues;
7042 let sections = run.sections;
7043 let production = resolved.production;
7044 health::run_health(
7045 &HealthOptions {
7046 root: dispatch.root,
7047 config_path: &cli.config,
7048 output,
7049 no_cache: cli.no_cache,
7050 threads: dispatch.threads,
7051 quiet,
7052 thresholds: run.thresholds,
7053 top: run.top,
7054 sort: run.sort,
7055 production,
7056 production_override: Some(production),
7057 allow_remote_extends: cli.allow_remote_extends,
7058 changed_since: cli.changed_since.as_deref(),
7059 diff_index: None,
7060 use_shared_diff_index: true,
7061 workspace: cli.workspace.as_deref(),
7062 changed_workspaces: cli.changed_workspaces.as_deref(),
7063 baseline: cli.baseline.as_deref(),
7064 save_baseline: cli.save_baseline.as_deref(),
7065 baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
7066 baseline_mode_explicit: cli.baseline_mode.is_some(),
7067 complexity: sections.complexity,
7068 file_scores: sections.file_scores,
7069 coverage_gaps: sections.coverage_gaps,
7070 config_activates_coverage_gaps: !sections.any_section,
7071 hotspots: sections.hotspots,
7072 ownership: run.ownership,
7073 ownership_emails: run.ownership_emails,
7074 targets: sections.targets,
7075 css: sections.css,
7076 css_deep: false,
7077 force_full: sections.force_full,
7078 score_only_output: sections.score_only_output,
7079 enforce_coverage_gap_gate: true,
7080 effort: run.effort,
7081 score: sections.score,
7082 gates: run.gates,
7083 since: run.since,
7084 min_commits: run.min_commits,
7085 explain: cli.explain,
7086 summary: cli.summary,
7087 save_snapshot: args
7088 .save_snapshot
7089 .map(|opt| PathBuf::from(opt.as_deref().unwrap_or_default())),
7090 trend: args.trend,
7091 coverage_inputs: run.coverage_inputs,
7092 performance: cli.performance,
7093 runtime_coverage: run.runtime_coverage,
7094 churn_file: cli.churn_file.as_deref(),
7095 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
7096 complexity_breakdown: args.complexity_breakdown,
7097 group_by: cli.group_by.map(Into::into),
7098 group_filter: cli.group.as_deref(),
7099 trend_from: args.trend_from,
7100 scope: args.scope.clone(),
7101 },
7102 dispatch.json_style,
7103 &health::TypeAwareHealthOptions {
7104 enabled: cli.type_aware_override(),
7105 requested: args.type_coupling,
7106 unfiltered: health_type_coupling_is_default_section(args),
7107 projects: &cli.type_aware_project,
7108 require: cli.type_aware_require.map(Into::into),
7109 },
7110 )
7111}
7112
7113fn health_type_coupling_is_default_section(args: &HealthDispatchArgs<'_>) -> bool {
7114 !args.complexity
7115 && !args.file_scores
7116 && !args.coverage_gaps
7117 && !args.hotspots
7118 && !args.ownership
7119 && !args.targets
7120 && !args.css
7121 && !args.score
7122 && args.min_score.is_none()
7123 && args.min_severity.is_none()
7124 && args.runtime_coverage.is_none()
7125}
7126
7127#[cfg(test)]
7128mod tests {
7129 use super::*;
7130
7131 #[test]
7135 fn cli_definition_has_no_flag_collisions() {
7136 use clap::CommandFactory;
7137 Cli::command().debug_assert();
7138 }
7139
7140 #[test]
7141 fn impact_statusline_subcommand_parses() {
7142 use clap::Parser;
7143
7144 let cli = Cli::try_parse_from(["fallow", "impact", "statusline"]).expect("argv parses");
7145 assert!(matches!(
7146 cli.command,
7147 Some(Command::Impact {
7148 subcommand: Some(ImpactCli::Statusline),
7149 ..
7150 })
7151 ));
7152 }
7153
7154 #[test]
7155 fn impact_statusline_bypasses_command_epilogue() {
7156 use clap::Parser;
7157
7158 let statusline =
7159 Cli::try_parse_from(["fallow", "impact", "statusline"]).expect("argv parses");
7160 assert!(is_impact_statusline(&statusline));
7161
7162 let status = Cli::try_parse_from(["fallow", "impact", "status"]).expect("argv parses");
7163 assert!(!is_impact_statusline(&status));
7164
7165 let all_statusline =
7166 Cli::try_parse_from(["fallow", "impact", "--all", "statusline"]).expect("argv parses");
7167 assert!(!is_impact_statusline(&all_statusline));
7168 }
7169
7170 #[test]
7171 fn regression_baseline_help_explains_the_default_destination() {
7172 use clap::CommandFactory;
7173 let help = Cli::command().render_long_help().to_string();
7174
7175 assert!(help.contains("Omit PATH to update regression.baseline"));
7176 assert!(help.contains("discovered fallow config"));
7177 assert!(help.contains("create .fallowrc.json when none exists"));
7178 }
7179
7180 #[test]
7184 fn after_help_lists_every_task_matrix_command() {
7185 for row in crate::task_matrix::TASK_MATRIX {
7186 assert!(
7187 TOP_LEVEL_AFTER_LONG_HELP.contains(row.command),
7188 "root --help cheat sheet is missing task-matrix command '{}'; \
7189 update the top_level_task_cheat_sheet! fragment to match TASK_MATRIX",
7190 row.command
7191 );
7192 }
7193 }
7194
7195 #[test]
7202 fn after_help_lists_every_visible_subcommand() {
7203 use clap::CommandFactory;
7204
7205 for sub in Cli::command().get_subcommands() {
7206 if sub.is_hide_set() {
7207 continue;
7208 }
7209 let name = sub.get_name();
7210 let listed = TOP_LEVEL_AFTER_LONG_HELP
7211 .lines()
7212 .any(|line| line.split_whitespace().next() == Some(name));
7213 assert!(
7214 listed,
7215 "root --help command list is missing subcommand '{name}'; \
7216 add it to a top_level_*_command_groups! section"
7217 );
7218 }
7219 }
7220
7221 #[test]
7225 fn short_help_stays_scannable_with_cheat_sheet_and_pointer() {
7226 use clap::CommandFactory;
7227
7228 let help = Cli::command().render_help().to_string();
7229 let lines = help.lines().count();
7230 assert!(
7231 lines < 90,
7232 "root -h grew to {lines} lines; keep the short surface under 90 \
7233 (curate hide_short_help and the short after-help instead)"
7234 );
7235 assert!(help.contains("When the agent is about to..."));
7236 assert!(help.contains("Run fallow --help for the complete command list."));
7237 }
7238
7239 #[test]
7243 fn high_value_commands_route_to_distinct_workflows() {
7244 use clap::Parser;
7245 use fallow_config::OutputFormat;
7246
7247 let distinct = [
7248 (vec!["fallow", "impact"], telemetry::Workflow::Impact),
7249 (vec!["fallow", "security"], telemetry::Workflow::Security),
7250 (vec!["fallow", "fix"], telemetry::Workflow::Fix),
7251 (
7252 vec!["fallow", "explain", "unused-exports"],
7253 telemetry::Workflow::Explain,
7254 ),
7255 (
7256 vec!["fallow", "watch"],
7257 telemetry::Workflow::CodeQualityReview,
7258 ),
7259 (
7260 vec!["fallow", "list"],
7261 telemetry::Workflow::ProjectInventory,
7262 ),
7263 (
7264 vec!["fallow", "workspaces"],
7265 telemetry::Workflow::ProjectInventory,
7266 ),
7267 (
7268 vec!["fallow", "schema"],
7269 telemetry::Workflow::ProjectInventory,
7270 ),
7271 (vec!["fallow", "init"], telemetry::Workflow::Setup),
7272 (
7273 vec!["fallow", "hooks", "install", "--target", "git"],
7274 telemetry::Workflow::Setup,
7275 ),
7276 (vec!["fallow", "config-schema"], telemetry::Workflow::Setup),
7277 (vec!["fallow", "plugin-schema"], telemetry::Workflow::Setup),
7278 (
7279 vec!["fallow", "rule-pack-schema"],
7280 telemetry::Workflow::Setup,
7281 ),
7282 (vec!["fallow", "config"], telemetry::Workflow::Setup),
7283 (
7284 vec!["fallow", "ci-template", "gitlab"],
7285 telemetry::Workflow::Setup,
7286 ),
7287 (vec!["fallow", "migrate"], telemetry::Workflow::Setup),
7288 (
7289 vec!["fallow", "telemetry", "status"],
7290 telemetry::Workflow::Setup,
7291 ),
7292 (vec!["fallow", "setup-hooks"], telemetry::Workflow::Setup),
7293 (
7294 vec!["fallow", "audit-cache", "remove", "--root", "."],
7295 telemetry::Workflow::Setup,
7296 ),
7297 (
7298 vec!["fallow", "license", "status"],
7299 telemetry::Workflow::License,
7300 ),
7301 ];
7302 for (argv, expected) in distinct {
7303 let cli = Cli::try_parse_from(&argv).expect("argv parses");
7304 assert_eq!(
7305 telemetry_workflow_for_command(cli.command.as_ref(), OutputFormat::Json),
7306 expected,
7307 "{argv:?} should map to {expected:?}"
7308 );
7309 }
7310 }
7311
7312 #[test]
7317 fn version_flag_accepts_lower_v_upper_v_and_long() {
7318 use clap::CommandFactory;
7319 for argv in [["fallow", "-v"], ["fallow", "-V"], ["fallow", "--version"]] {
7320 let err = Cli::command()
7321 .try_get_matches_from(argv)
7322 .expect_err("version flag should short-circuit parsing");
7323 assert_eq!(
7324 err.kind(),
7325 clap::error::ErrorKind::DisplayVersion,
7326 "{argv:?} should trigger the Version action"
7327 );
7328 }
7329 }
7330
7331 #[test]
7336 fn cli_help_text_contains_no_implementation_status_wording() {
7337 use clap::CommandFactory;
7338 let mut root = Cli::command();
7339 let mut violations: Vec<(String, String)> = Vec::new();
7340 visit_help(&mut root, "fallow", &mut violations);
7341 assert!(
7342 violations.is_empty(),
7343 "found implementation-status wording in --help output:\n{}",
7344 violations
7345 .iter()
7346 .map(|(cmd, line)| format!(" {cmd}: {line}"))
7347 .collect::<Vec<_>>()
7348 .join("\n")
7349 );
7350 }
7351
7352 #[test]
7353 fn dependency_override_help_is_package_manager_neutral() {
7354 use clap::CommandFactory;
7355 let help = Cli::command()
7356 .find_subcommand_mut("dead-code")
7357 .expect("dead-code command")
7358 .render_long_help()
7359 .to_string();
7360
7361 assert!(help.contains("Only report unused package-manager dependency overrides"));
7362 assert!(help.contains("Only report misconfigured package-manager dependency overrides"));
7363 assert!(!help.contains("unused pnpm dependency overrides"));
7364 assert!(!help.contains("misconfigured pnpm dependency overrides"));
7365 }
7366
7367 #[test]
7368 fn top_level_help_groups_commands_by_workflow() {
7369 use clap::CommandFactory;
7370 let help = Cli::command().render_long_help().to_string();
7371 let expected_order = [
7372 "Analysis:",
7373 " dead-code",
7374 " dupes",
7375 " health",
7376 " flags",
7377 " security",
7378 " audit",
7379 "Workflow:",
7380 " watch",
7381 " fix",
7382 "Project inspection:",
7383 " list",
7384 " workspaces",
7385 " explain",
7386 " impact",
7387 " viz",
7388 "Setup and configuration:",
7389 " init",
7390 " recommend",
7391 " migrate",
7392 " config",
7393 " config-schema",
7394 " plugin-schema",
7395 " plugin-check",
7396 " rule-pack-schema",
7397 "Automation and CI:",
7398 " ci",
7399 " ci-template",
7400 " hooks",
7401 " setup-hooks",
7402 "Runtime coverage:",
7403 " coverage",
7404 " license",
7405 "Reference:",
7406 " schema",
7407 " help",
7408 "Options:",
7409 ];
7410 let mut cursor = 0;
7411 for needle in expected_order {
7412 let Some(offset) = help[cursor..].find(needle) else {
7413 panic!("top-level help missing `{needle}` after byte {cursor}:\n{help}");
7414 };
7415 cursor += offset + needle.len();
7416 }
7417 }
7418
7419 #[test]
7423 fn every_global_dupes_override_is_rejected_where_dupes_does_not_run() {
7424 let longs: Vec<String> = <Cli as clap::CommandFactory>::command()
7425 .get_arguments()
7426 .filter_map(|arg| arg.get_long())
7427 .filter(|long| long.starts_with("dupes-") && *long != "dupes-baseline")
7428 .map(str::to_owned)
7429 .collect();
7430 assert!(
7431 longs.len() > 1,
7432 "expected global --dupes-* flags, got {longs:?}"
7433 );
7434 let security_help = render_security_help(SecurityHelpTarget::Parent);
7435
7436 for long in &longs {
7437 let flag = format!("--{long}");
7438 let takes_value = <Cli as clap::CommandFactory>::command()
7439 .get_arguments()
7440 .find(|arg| arg.get_long() == Some(long.as_str()))
7441 .is_some_and(|arg| arg.get_action().takes_values());
7442 let value = if long == "dupes-mode" { "weak" } else { "3" };
7443 let argv_for = |command: &[&str]| {
7444 let mut argv = vec!["fallow"];
7445 argv.extend_from_slice(command);
7446 argv.push(flag.as_str());
7447 if takes_value {
7448 argv.push(value);
7449 }
7450 Cli::try_parse_from(argv).expect("global dupes flag parses")
7451 };
7452
7453 let security = argv_for(&["security"]);
7454 assert_eq!(unsupported_security_global(&security), Some(flag.as_str()));
7455 let doctor = argv_for(&["doctor"]);
7456 assert_eq!(unsupported_doctor_option(&doctor), Some(flag.as_str()));
7457 let similar_code = argv_for(&["similar-code", "status"]);
7458 assert_eq!(
7459 similar_code_help::unsupported_similar_code_option(&similar_code),
7460 Some(flag.as_str())
7461 );
7462 assert!(
7463 !help_contains_long_flag(&security_help, long),
7464 "security help must hide unsupported {flag}:\n{security_help}"
7465 );
7466 }
7467 }
7468
7469 #[test]
7470 fn security_help_hides_globals_rejected_by_security_validator() {
7471 let help = render_security_help(SecurityHelpTarget::Parent);
7472
7473 for long in SECURITY_UNSUPPORTED_GLOBAL_LONGS {
7474 assert!(
7475 !help_contains_long_flag(&help, long),
7476 "security help must hide unsupported --{long}:\n{help}"
7477 );
7478 }
7479
7480 for long in [
7481 "root",
7482 "config",
7483 "format",
7484 "quiet",
7485 "no-cache",
7486 "threads",
7487 "changed-since",
7488 "diff-file",
7489 "diff-stdin",
7490 "workspace",
7491 "changed-workspaces",
7492 "ci",
7493 "fail-on-issues",
7494 "sarif-file",
7495 "summary",
7496 "output-file",
7497 "max-file-size",
7498 "explain",
7499 "surface",
7500 ] {
7501 assert!(
7502 help_contains_long_flag(&help, long),
7503 "security help must keep supported --{long}:\n{help}"
7504 );
7505 }
7506 }
7507
7508 #[test]
7509 fn security_help_detection_covers_subcommand_and_help_alias_forms() {
7510 assert_eq!(
7511 security_help_target(["security", "--help"]),
7512 Some(SecurityHelpTarget::Parent)
7513 );
7514 assert_eq!(
7515 security_help_target(["security", "-h"]),
7516 Some(SecurityHelpTarget::Parent)
7517 );
7518 assert_eq!(
7519 security_help_target(["--format", "json", "security", "--help"]),
7520 Some(SecurityHelpTarget::Parent)
7521 );
7522 assert_eq!(
7523 security_help_target(["help", "security"]),
7524 Some(SecurityHelpTarget::Parent)
7525 );
7526 assert_eq!(
7527 security_help_target(["security", "survivors", "--help"]),
7528 Some(SecurityHelpTarget::Survivors)
7529 );
7530 assert_eq!(
7531 security_help_target(["security", "survivors", "-h"]),
7532 Some(SecurityHelpTarget::Survivors)
7533 );
7534 assert_eq!(
7535 security_help_target(["help", "security", "survivors"]),
7536 Some(SecurityHelpTarget::Survivors)
7537 );
7538 assert_eq!(
7539 security_help_target(["security", "blind-spots", "--help"]),
7540 Some(SecurityHelpTarget::BlindSpots)
7541 );
7542 assert_eq!(
7543 security_help_target(["help", "security", "blind-spots"]),
7544 Some(SecurityHelpTarget::BlindSpots)
7545 );
7546 assert_eq!(security_help_target(["health", "--help"]), None);
7547 assert_eq!(security_help_target(["help", "health"]), None);
7548 }
7549
7550 #[test]
7551 fn security_unsupported_global_validator_matches_hidden_help_contract() {
7552 for (argv, expected) in [
7553 (vec!["fallow", "security", "--performance"], "--performance"),
7554 (
7555 vec!["fallow", "security", "--no-package-baselines"],
7556 "--no-package-baselines",
7557 ),
7558 (
7559 vec!["fallow", "security", "--baseline", "base.json"],
7560 "--baseline",
7561 ),
7562 (
7563 vec!["fallow", "security", "--fail-on-stale-baseline"],
7564 "--fail-on-stale-baseline",
7565 ),
7566 (
7567 vec!["fallow", "security", "--dupes-mode", "weak"],
7568 "--dupes-mode",
7569 ),
7570 (vec!["fallow", "security", "--dupes-near"], "--dupes-near"),
7571 ] {
7572 let cli = Cli::try_parse_from(argv).expect("security global parses before validation");
7573 assert_eq!(unsupported_security_global(&cli), Some(expected));
7574 }
7575
7576 let explain = Cli::try_parse_from(["fallow", "security", "--explain"])
7577 .expect("security --explain parses");
7578 assert_eq!(unsupported_security_global(&explain), None);
7579 }
7580
7581 #[test]
7582 fn programmatic_common_options_track_analysis_affecting_cli_globals() {
7583 use clap::CommandFactory;
7584
7585 let cli_flags: std::collections::BTreeSet<String> = Cli::command()
7586 .get_arguments()
7587 .filter(|arg| arg.is_global_set())
7588 .filter_map(|arg| arg.get_long().map(str::to_owned))
7589 .filter(|name| {
7590 matches!(
7591 name.as_str(),
7592 "root"
7593 | "config"
7594 | "allow-remote-extends"
7595 | "no-cache"
7596 | "threads"
7597 | "changed-since"
7598 | "diff-file"
7599 | "production"
7600 | "workspace"
7601 | "changed-workspaces"
7602 | "explain"
7603 )
7604 })
7605 .collect();
7606 let programmatic_flags: std::collections::BTreeSet<String> =
7607 fallow_api::COMMON_ANALYSIS_OPTION_FLAGS
7608 .iter()
7609 .map(|flag| (*flag).to_owned())
7610 .collect();
7611
7612 assert_eq!(programmatic_flags, cli_flags);
7613 }
7614
7615 #[test]
7616 fn dead_code_registry_filter_flags_are_exposed_by_clap() {
7617 use clap::CommandFactory;
7618
7619 let cli = Cli::command();
7620 let dead_code = cli
7621 .get_subcommands()
7622 .find(|command| command.get_name() == "dead-code")
7623 .expect("dead-code subcommand is registered");
7624 let cli_flags: std::collections::BTreeSet<String> = dead_code
7625 .get_arguments()
7626 .filter_map(|arg| arg.get_long().map(|long| format!("--{long}")))
7627 .collect();
7628
7629 for flag in fallow_types::issue_meta::DEAD_CODE_FILTER_FLAGS.iter() {
7630 assert!(
7631 cli_flags.contains(*flag),
7632 "registry filter flag {flag} is missing from dead-code clap args"
7633 );
7634 }
7635 }
7636
7637 fn help_contains_long_flag(help: &str, long: &str) -> bool {
7638 let flag = format!("--{long}");
7639 help.split(|c: char| c.is_whitespace() || c == ',' || c == '[' || c == ']')
7640 .any(|token| token == flag)
7641 }
7642
7643 fn visit_help(cmd: &mut clap::Command, path: &str, violations: &mut Vec<(String, String)>) {
7644 let help = cmd.render_long_help().to_string();
7645 for line in scan_forbidden(&help) {
7646 violations.push((path.to_owned(), line));
7647 }
7648 let names: Vec<String> = cmd
7649 .get_subcommands()
7650 .map(|sub| sub.get_name().to_owned())
7651 .collect();
7652 for name in names {
7653 if name == "help" {
7654 continue;
7655 }
7656 if let Some(sub) = cmd.find_subcommand_mut(&name) {
7657 let sub_path = format!("{path} {name}");
7658 visit_help(sub, &sub_path, violations);
7659 }
7660 }
7661 }
7662
7663 fn scan_forbidden(s: &str) -> Vec<String> {
7664 let lower = s.to_ascii_lowercase();
7665 let mut out = Vec::new();
7666 for word in ["stub", "placeholder"] {
7667 if let Some(idx) = find_whole_word(&lower, word) {
7668 out.push(extract_line(s, idx));
7669 }
7670 }
7671 if let Some(idx) = lower.find("not yet") {
7672 out.push(extract_line(s, idx));
7673 }
7674 out
7675 }
7676
7677 fn find_whole_word(haystack: &str, word: &str) -> Option<usize> {
7678 let bytes = haystack.as_bytes();
7679 let mut start = 0;
7680 while let Some(rel) = haystack[start..].find(word) {
7681 let abs = start + rel;
7682 let before_ok = abs == 0 || !bytes[abs - 1].is_ascii_alphanumeric();
7683 let after_idx = abs + word.len();
7684 let after_ok = after_idx >= bytes.len() || !bytes[after_idx].is_ascii_alphanumeric();
7685 if before_ok && after_ok {
7686 return Some(abs);
7687 }
7688 start = abs + word.len();
7689 }
7690 None
7691 }
7692
7693 fn extract_line(s: &str, byte_idx: usize) -> String {
7694 let line_start = s[..byte_idx].rfind('\n').map_or(0, |i| i + 1);
7695 let line_end = s[byte_idx..].find('\n').map_or(s.len(), |i| byte_idx + i);
7696 s[line_start..line_end].trim().to_owned()
7697 }
7698
7699 #[test]
7700 fn emit_error_returns_given_exit_code() {
7701 let code = emit_error("test error", 2, fallow_config::OutputFormat::Human);
7702 assert_eq!(code, ExitCode::from(2));
7703 }
7704
7705 fn telemetry_run_for_mode(mode: telemetry::AnalysisMode) -> TelemetryRun {
7706 TelemetryRun {
7707 workflow: telemetry::Workflow::Health,
7708 output: fallow_config::OutputFormat::Json,
7709 quiet: true,
7710 start: std::time::Instant::now(),
7711 context: telemetry::WorkflowContext {
7712 run_scope: telemetry::RunScope::FullProject,
7713 config_shape: telemetry::ConfigShape::Default,
7714 output_destination: telemetry::OutputDestination::Stdout,
7715 analysis_mode: mode,
7716 },
7717 }
7718 }
7719
7720 #[test]
7721 fn fallback_failure_reason_skips_success_and_findings() {
7722 let run = telemetry_run_for_mode(telemetry::AnalysisMode::Static);
7723
7724 assert_eq!(fallback_failure_reason_for(&run, ExitCode::SUCCESS), None);
7725 assert_eq!(fallback_failure_reason_for(&run, ExitCode::from(1)), None);
7726 }
7727
7728 #[test]
7729 fn fallback_failure_reason_classifies_network_auth_and_analysis() {
7730 let static_run = telemetry_run_for_mode(telemetry::AnalysisMode::Static);
7731 let cloud_run = telemetry_run_for_mode(telemetry::AnalysisMode::ProductionCoverage);
7732
7733 assert_eq!(
7734 fallback_failure_reason_for(&static_run, ExitCode::from(api::NETWORK_EXIT_CODE)),
7735 Some(telemetry::FailureReason::Network),
7736 );
7737 assert_eq!(
7738 fallback_failure_reason_for(&static_run, ExitCode::from(12)),
7739 Some(telemetry::FailureReason::Auth),
7740 );
7741 assert_eq!(
7742 fallback_failure_reason_for(&cloud_run, ExitCode::from(3)),
7743 Some(telemetry::FailureReason::Auth),
7744 );
7745 assert_eq!(
7746 fallback_failure_reason_for(&static_run, ExitCode::from(2)),
7747 Some(telemetry::FailureReason::Analysis),
7748 );
7749 }
7750
7751 #[test]
7752 fn bare_coverage_flags_parse_without_subcommand() {
7753 let cli = Cli::try_parse_from([
7754 "fallow",
7755 "--coverage",
7756 "coverage/coverage-final.json",
7757 "--coverage-root",
7758 "/ci/workspace",
7759 ])
7760 .expect("bare combined coverage flags should parse");
7761 assert!(cli.command.is_none());
7762 assert_eq!(
7763 cli.coverage.as_deref(),
7764 Some(std::path::Path::new("coverage/coverage-final.json"))
7765 );
7766 assert_eq!(
7767 cli.coverage_root.as_deref(),
7768 Some(std::path::Path::new("/ci/workspace"))
7769 );
7770 }
7771
7772 #[test]
7773 fn bare_coverage_before_subcommand_is_detectable() {
7774 let cli = Cli::try_parse_from([
7775 "fallow",
7776 "--coverage",
7777 "coverage/coverage-final.json",
7778 "dead-code",
7779 ])
7780 .expect("clap should parse pre-subcommand bare coverage for custom rejection");
7781 assert!(cli.command.is_some());
7782 assert!(cli_has_bare_coverage_input(&cli));
7783 let message = bare_coverage_subcommand_error_message();
7784 assert!(message.contains("bare combined-mode flags"));
7785 assert!(message.contains("fallow health --coverage <coverage-final.json>"));
7786 }
7787
7788 #[test]
7789 fn bare_combined_baseline_before_subcommand_is_detectable() {
7790 for flag in ["--dupes-baseline", "--health-baseline"] {
7791 let cli = Cli::try_parse_from(["fallow", flag, "x.json", "dead-code"])
7792 .expect("clap should parse a pre-subcommand combined baseline");
7793 assert!(cli.command.is_some());
7794 assert_eq!(cli_bare_combined_baseline_flag(&cli), Some(flag));
7795 let message = bare_combined_baseline_subcommand_error_message(flag);
7796 assert!(message.contains(flag));
7797 assert!(message.contains("omit the subcommand"));
7798 }
7799 let bare = Cli::try_parse_from(["fallow", "--dupes-baseline", "x.json"])
7800 .expect("bare combined baseline should parse");
7801 assert!(bare.command.is_none());
7802 }
7803
7804 #[test]
7805 fn subcommand_coverage_flag_keeps_regular_clap_error() {
7806 let Err(err) = Cli::try_parse_from(["fallow", "dead-code", "--coverage"]) else {
7807 panic!("dead-code --coverage should fail to parse");
7808 };
7809 assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
7810 }
7811
7812 #[test]
7813 fn type_aware_flags_parse_for_semantic_analysis() {
7814 let cli = Cli::try_parse_from([
7815 "fallow",
7816 "dead-code",
7817 "--unused-class-members",
7818 "--type-aware",
7819 "--type-aware-project",
7820 "tsconfig.json",
7821 "--type-aware-project",
7822 "packages/web/tsconfig.json",
7823 ])
7824 .expect("type-aware flag should parse");
7825 assert!(cli.type_aware);
7826 assert_eq!(
7827 cli.type_aware_project,
7828 [
7829 PathBuf::from("tsconfig.json"),
7830 PathBuf::from("packages/web/tsconfig.json")
7831 ]
7832 );
7833 let Some(Command::Check {
7834 unused_class_members,
7835 ..
7836 }) = cli.command
7837 else {
7838 panic!("dead-code should parse as the check command");
7839 };
7840 assert!(unused_class_members);
7841 }
7842
7843 #[test]
7844 fn no_type_aware_conflicts_with_type_aware() {
7845 let Err(err) = Cli::try_parse_from(["fallow", "audit", "--type-aware", "--no-type-aware"])
7846 else {
7847 panic!("--no-type-aware must conflict with --type-aware");
7848 };
7849 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
7850 }
7851
7852 #[test]
7853 fn no_type_aware_forces_semantic_analysis_off() {
7854 let cli = Cli::try_parse_from(["fallow", "audit", "--no-type-aware"])
7855 .expect("--no-type-aware should parse on audit");
7856 assert_eq!(cli.type_aware_override(), Some(false));
7857
7858 let cli = Cli::try_parse_from(["fallow", "dead-code", "--type-aware"])
7859 .expect("--type-aware should parse");
7860 assert_eq!(cli.type_aware_override(), Some(true));
7861
7862 let cli = Cli::try_parse_from(["fallow", "dead-code"]).expect("bare command should parse");
7863 assert_eq!(cli.type_aware_override(), None);
7864 }
7865
7866 #[test]
7867 fn type_aware_status_output_hides_host_paths() {
7868 let root = Path::new("/private/work/project");
7869 let output = type_aware_status_output(
7870 root,
7871 fallow_api::TypeAwareStatus {
7872 available: false,
7873 discovery_source: Some("environment-override"),
7874 companion_path: Some(PathBuf::from("/private/tools/fallow-type-aware")),
7875 package_version: None,
7876 protocol_version: 7,
7877 backend_family: None,
7878 backend_version: None,
7879 remediation: Some(
7880 "failed to launch /private/tools/fallow-type-aware from /private/work/project"
7881 .to_string(),
7882 ),
7883 },
7884 );
7885
7886 assert_eq!(
7887 output.schema_version.0,
7888 fallow_output::TYPE_AWARE_STATUS_SCHEMA_VERSION
7889 );
7890 assert_eq!(output.companion_path.as_deref(), Some("fallow-type-aware"));
7891 let remediation = output.remediation.expect("remediation");
7892 assert!(!remediation.contains("/private/"));
7893 assert!(remediation.contains("fallow-type-aware"));
7894 }
7895
7896 #[test]
7897 fn format_parsing_covers_all_variants() {
7898 assert!(matches!(parse_format_arg("json"), Some(Format::Json)));
7899 assert!(matches!(parse_format_arg("JSON"), Some(Format::Json)));
7900 assert!(matches!(parse_format_arg("human"), Some(Format::Human)));
7901 assert!(matches!(parse_format_arg("sarif"), Some(Format::Sarif)));
7902 assert!(matches!(parse_format_arg("compact"), Some(Format::Compact)));
7903 assert!(matches!(
7904 parse_format_arg("markdown"),
7905 Some(Format::Markdown)
7906 ));
7907 assert!(matches!(parse_format_arg("md"), Some(Format::Markdown)));
7908 assert!(matches!(
7909 parse_format_arg("codeclimate"),
7910 Some(Format::CodeClimate)
7911 ));
7912 assert!(matches!(
7913 parse_format_arg("gitlab-codequality"),
7914 Some(Format::CodeClimate)
7915 ));
7916 assert!(matches!(
7917 parse_format_arg("gitlab-code-quality"),
7918 Some(Format::CodeClimate)
7919 ));
7920 assert!(matches!(
7921 parse_format_arg("pr-comment-github"),
7922 Some(Format::PrCommentGithub)
7923 ));
7924 assert!(matches!(
7925 parse_format_arg("pr-comment-gitlab"),
7926 Some(Format::PrCommentGitlab)
7927 ));
7928 assert!(matches!(
7929 parse_format_arg("review-github"),
7930 Some(Format::ReviewGithub)
7931 ));
7932 assert!(matches!(
7933 parse_format_arg("review-gitlab"),
7934 Some(Format::ReviewGitlab)
7935 ));
7936 assert!(matches!(parse_format_arg("badge"), Some(Format::Badge)));
7937 assert!(parse_format_arg("xml").is_none());
7938 assert!(parse_format_arg("").is_none());
7939 }
7940
7941 #[test]
7942 fn quiet_parsing_logic() {
7943 let parse = |s: &str| -> bool { s == "1" || s.eq_ignore_ascii_case("true") };
7944 assert!(parse("1"));
7945 assert!(parse("true"));
7946 assert!(parse("TRUE"));
7947 assert!(parse("True"));
7948 assert!(!parse("0"));
7949 assert!(!parse("false"));
7950 assert!(!parse("yes"));
7951 }
7952
7953 #[test]
7954 fn tracing_filter_defaults_to_warn_without_env() {
7955 assert_eq!(build_tracing_filter(None).to_string(), "warn");
7956 }
7957
7958 #[test]
7959 fn tracing_filter_respects_explicit_env_directives() {
7960 assert_eq!(build_tracing_filter(Some("info")).to_string(), "info");
7961 }
7962
7963 #[test]
7964 fn tracing_filter_treats_empty_env_as_off() {
7965 assert_eq!(build_tracing_filter(Some("")).to_string(), "off");
7966 assert_eq!(build_tracing_filter(Some(" ")).to_string(), "off");
7967 }
7968}