1#![expect(
2 clippy::print_stdout,
3 clippy::print_stderr,
4 reason = "CLI binary produces intentional terminal output"
5)]
6#![cfg_attr(
7 test,
8 allow(
9 clippy::unwrap_used,
10 clippy::expect_used,
11 reason = "tests use unwrap and expect to keep fixture setup concise"
12 )
13)]
14
15use std::io::IsTerminal as _;
16use std::path::{Path, PathBuf};
17use std::process::ExitCode;
18
19use clap::{Parser, Subcommand};
20
21mod api;
22#[cfg(test)]
23mod architecture_boundaries;
24mod audit;
25mod audit_brief;
26mod audit_cache_prune;
27mod audit_decision_surface;
28mod audit_focus;
29mod audit_walkthrough;
30mod base_worktree;
31pub use base_worktree::canonical_root_hash;
35mod walkthrough_state;
36use fallow_engine::baseline;
37mod agent_install;
38mod baseline_gate;
39mod cache_notice;
40mod check;
41mod ci;
42mod ci_template;
43mod cli_agent;
44mod cli_format;
45mod cli_hooks;
46mod cli_impact;
47mod cli_production;
48mod cli_report;
49mod cli_startup;
50pub use fallow_engine::codeowners;
51mod combined;
52mod config;
53mod coverage;
54mod discovery_note;
55mod doctor;
56mod dupes;
57mod exit_codes;
58pub mod explain;
59mod fix;
60mod flags;
61mod flags_retirement_formats;
62mod gates;
63mod guard;
64mod health;
65mod impact;
66mod init;
67mod inspect;
68mod json_style;
69mod license;
70mod list;
71mod migrate;
72mod onboarding;
73#[cfg(test)]
74mod output_envelope;
75mod output_runtime;
76mod path_util;
77mod plugin_check;
78mod process_clock;
79mod rayon_pool;
80mod regression;
81pub mod report;
82mod requests;
83mod rule_pack;
84mod runtime_support;
85mod schema;
86mod scope_path;
87mod security;
88mod security_help;
89mod selector;
90mod setup_hooks;
91mod signal;
92mod similar_code_cli;
93mod similar_code_help;
94mod suppressions;
95mod task_matrix;
96mod telemetry;
97mod trace_chain;
98mod trace_error;
99mod trace_path;
100mod type_aware_degrade;
101mod update_check;
102use fallow_engine::validate;
103use fallow_engine::vital_signs;
104mod cli_telemetry;
105mod viz;
106mod watch;
107mod write_scope;
108
109use check::{CheckOptions, IssueFilters, TraceOptions};
110pub(crate) mod error;
112use cli_agent::{AgentCli, run_agent_command};
113#[cfg(test)]
114use cli_format::parse_format_arg;
115use cli_format::{Format, FormatConfig};
116use cli_hooks::{HooksCli, run_hooks_command};
117use cli_impact::{ImpactCli, ImpactCrossRepoOpts, ImpactSortCli, dispatch_impact};
118use cli_production::{ProductionModes, resolve_production_modes};
119#[cfg(test)]
120use cli_startup::build_tracing_filter;
121use cli_startup::{
122 bare_combined_baseline_subcommand_error_message, bare_coverage_subcommand_error_message,
123 cli_bare_combined_baseline_flag, cli_has_bare_coverage_input, parse_cli_args,
124 reject_global_baseline_flags, run_pre_dispatch_checks, setup_tracing, validate_inputs,
125};
126#[cfg(test)]
127use cli_telemetry::TelemetryRun;
128#[cfg(test)]
129use cli_telemetry::{fallback_failure_reason_for, telemetry_workflow_for_command};
130use cli_telemetry::{record_run_epilogue, start_telemetry_run};
131use dupes::{DupesMode, DupesOptions};
132use error::emit_error;
133use health::{HealthOptions, SortBy};
134use list::ListOptions;
135pub(crate) use runtime_support::{AnalysisKind, GroupBy};
136pub(crate) use runtime_support::{
137 ConfigLoadOptions, LoadConfigArgs, build_ownership_resolver, load_config,
138 load_config_for_analysis,
139};
140#[cfg(test)]
141use security_help::{SECURITY_UNSUPPORTED_GLOBAL_LONGS, SecurityHelpTarget};
142use security_help::{render_security_help, security_help_target};
143use similar_code_help::{render_similar_code_help, similar_code_help_target};
144
145const DEFAULT_MIN_INVOCATIONS_HOT: u64 = 100;
146
147const TOP_LEVEL_HELP_TEMPLATE: &str =
148 "{about-with-newline}\n{usage-heading} {usage}{after-help}\n\nOptions:\n{options}";
149
150macro_rules! top_level_task_cheat_sheet {
153 () => {
154 "\
155When the agent is about to...
156 delete an \"unused\" export or file fallow dead-code --trace <file>:<export>
157 prove exact TypeScript symbol consumers fallow dead-code --type-aware --symbol-impact <file>:<export-or-class.method>
158 find how one module reaches another fallow trace --path <from> <to>
159 delete an \"unused\" dependency fallow dead-code --trace-dependency <name>
160 commit or open a PR fallow audit --base <ref>
161 read a diff before approving it fallow review --base <ref> --brief
162 prioritize refactoring fallow health --hotspots --targets
163 ask who owns code fallow health --ownership
164 check untested-but-reachable code fallow health --coverage-gaps
165 consolidate duplication fallow dupes --trace dup:<fingerprint>
166 find feature flags fallow flags
167 check architecture rules before editing fallow guard <files>
168 surface security candidates fallow security
169 inspect a target before editing fallow inspect --file <path>
170 understand a finding fallow explain <issue-type>
171 scope a monorepo --workspace <glob> / --changed-workspaces <ref>"
172 };
173}
174
175macro_rules! top_level_core_command_groups {
176 () => {
177 "\
178Analysis:
179 dead-code Analyze unused code, dependency hygiene, and architecture cycles
180 dupes Find copy-paste and structural code duplication
181 health Analyze complexity, maintainability, hotspots, and coverage gaps
182 flags Detect feature flag usage patterns
183 security Surface local security candidates for agent verification (opt-in)
184 similar-code Find semantic implementation overlap for verification (opt-in, local)
185 audit Review changed files for dead code, complexity, duplication, and styling
186
187Workflow:
188 watch Re-run analysis as files change
189 fix Auto-fix safe unused-code findings"
190 };
191}
192
193macro_rules! top_level_extended_command_groups {
194 () => {
195 "\
196Project inspection:
197 list List discovered files, entry points, plugins, boundaries, workspaces, and entry weight
198 inspect Inspect one file or exported symbol as a bundled evidence query
199 trace Trace a symbol's call chain (best-effort, syntactic)
200 trace-error Resolve a runtime stack trace's frames to project definitions
201 guard Show which architecture rules apply to files before editing
202 decision-surface Surface the structural decisions a change embeds (advisory)
203 workspaces Show monorepo workspace discovery diagnostics
204 explain Explain one issue type without running analysis
205 suppressions List active fallow-ignore suppression markers
206 impact Show what fallow has done for you (opt-in, local-only)
207 viz Generate an interactive HTML map of the codebase
208
209Setup and configuration:
210 doctor Diagnose project readiness without changing anything
211 init Create a fallow config, optionally with a Git hook
212 agent Wire fallow into Claude Code, Codex, or Cursor in one pass
213 audit-cache Maintain reusable audit base-snapshot caches
214 recommend Recommend a project-tailored config for an agent to author
215 migrate Migrate knip, jscpd, or stylelint config to fallow
216 config Show the resolved config and loaded config file
217 config-schema Print the fallow config JSON Schema
218 plugin-schema Print the external plugin JSON Schema
219 plugin-check Dry-run external plugins and report what they seed
220 rule-pack Manage declarative rule packs (policy-as-code)
221 rule-pack-schema Print the rule pack JSON Schema
222 type-aware Inspect the optional TypeScript semantic companion
223
224Automation and CI:
225 ci Build PR/MR feedback envelopes
226 ci-template Print or vendor CI integration templates
227 report Re-render saved JSON as GitHub or CodeClimate output
228 hooks Install or remove fallow-managed Git and agent hooks
229 setup-hooks Deprecated: use `agent install` or `hooks install --target agent`
230
231Runtime coverage:
232 coverage Set up or analyze runtime coverage data
233 license Manage the paid-feature license
234 telemetry Manage opt-in product telemetry
235
236Reference:
237 schema Dump the CLI interface as machine-readable JSON
238 help Print this message or the help of a command"
239 };
240}
241
242const TOP_LEVEL_AFTER_HELP: &str = concat!(
243 top_level_task_cheat_sheet!(),
244 "\n\n",
245 top_level_core_command_groups!(),
246 "\n\nRun fallow --help for the complete command list."
247);
248
249const TOP_LEVEL_AFTER_LONG_HELP: &str = concat!(
250 top_level_task_cheat_sheet!(),
251 "\n\n",
252 top_level_core_command_groups!(),
253 "\n\n",
254 top_level_extended_command_groups!(),
255 "\n\n",
256 "When no command is given, fallow runs dead-code + dupes + health together.\n",
257 "Use --only/--skip to select specific analyses."
258);
259
260#[derive(Parser)]
261#[command(
262 name = "fallow",
263 about = "Codebase analyzer for TypeScript/JavaScript: unused code, circular dependencies, code duplication, complexity hotspots, and architecture boundary violations",
264 version,
265 disable_version_flag = true,
266 help_template = TOP_LEVEL_HELP_TEMPLATE,
267 after_help = TOP_LEVEL_AFTER_HELP,
268 after_long_help = TOP_LEVEL_AFTER_LONG_HELP
269)]
270struct Cli {
271 #[command(subcommand)]
272 command: Option<Command>,
273
274 #[arg(value_name = "PATH")]
277 path: Option<PathBuf>,
278
279 #[arg(
283 short = 'v',
284 visible_short_alias = 'V',
285 long = "version",
286 action = clap::ArgAction::Version
287 )]
288 version: Option<bool>,
289
290 #[arg(short, long, global = true)]
292 root: Option<PathBuf>,
293
294 #[arg(short, long, global = true)]
296 config: Option<PathBuf>,
297
298 #[arg(hide_short_help = true, long, global = true)]
300 allow_remote_extends: bool,
301
302 #[arg(
304 short,
305 long,
306 visible_alias = "output",
307 global = true,
308 default_value = "human"
309 )]
310 format: Format,
311
312 #[arg(hide_short_help = true, long, global = true)]
314 pretty: bool,
315
316 #[arg(short, long, global = true)]
318 quiet: bool,
319
320 #[arg(hide_short_help = true, long, global = true)]
322 no_cache: bool,
323
324 #[arg(hide_short_help = true, long, global = true)]
326 threads: Option<usize>,
327
328 #[arg(long, visible_alias = "base", global = true)]
330 changed_since: Option<String>,
331
332 #[arg(
337 hide_short_help = true,
338 long = "diff-file",
339 value_name = "PATH",
340 global = true
341 )]
342 diff_file: Option<PathBuf>,
343
344 #[arg(hide_short_help = true, long = "diff-stdin", global = true)]
347 diff_stdin: bool,
348
349 #[arg(
356 hide_short_help = true,
357 long = "churn-file",
358 value_name = "PATH",
359 global = true
360 )]
361 churn_file: Option<PathBuf>,
362
363 #[arg(
370 hide_short_help = true,
371 long = "max-file-size",
372 value_name = "MB",
373 global = true
374 )]
375 max_file_size: Option<u32>,
376
377 #[arg(hide_short_help = true, long, global = true)]
380 baseline: Option<PathBuf>,
381
382 #[arg(
397 hide_short_help = true,
398 long = "baseline-mode",
399 value_enum,
400 global = true
401 )]
402 baseline_mode: Option<BaselineModeArg>,
403
404 #[arg(long, global = true, value_name = "RUN_ID", hide = true)]
410 parent_run: Option<String>,
411
412 #[arg(hide_short_help = true, long, global = true)]
416 save_baseline: Option<PathBuf>,
417
418 #[arg(long, global = true)]
421 production: bool,
422
423 #[arg(
427 hide_short_help = true,
428 long = "no-production",
429 global = true,
430 conflicts_with = "production"
431 )]
432 no_production: bool,
433
434 #[arg(hide_short_help = true, long = "production-dead-code")]
436 production_dead_code: bool,
437
438 #[arg(hide_short_help = true, long = "production-health")]
440 production_health: bool,
441
442 #[arg(hide_short_help = true, long = "production-dupes")]
444 production_dupes: bool,
445
446 #[arg(short, long, global = true, value_delimiter = ',')]
450 workspace: Option<Vec<String>>,
451
452 #[arg(long, global = true, value_name = "REF")]
455 changed_workspaces: Option<String>,
456
457 #[arg(hide_short_help = true, long, global = true)]
459 group_by: Option<GroupBy>,
460
461 #[arg(hide_short_help = true, long, global = true)]
463 performance: bool,
464
465 #[arg(hide_short_help = true, long, global = true)]
467 explain: bool,
468
469 #[arg(hide_short_help = true, long, global = true)]
472 explain_skipped: bool,
473
474 #[arg(hide_short_help = true, long, global = true)]
476 summary: bool,
477
478 #[arg(long, global = true)]
480 ci: bool,
481
482 #[arg(hide_short_help = true, long, global = true)]
484 fail_on_issues: bool,
485
486 #[arg(hide_short_help = true, long, global = true, value_name = "PATH")]
491 sarif_file: Option<PathBuf>,
492
493 #[arg(short = 'o', long, global = true, value_name = "PATH")]
499 output_file: Option<PathBuf>,
500
501 #[arg(
511 hide_short_help = true,
512 long = "report-path-prefix",
513 visible_alias = "annotations-path-prefix",
514 global = true,
515 value_name = "PREFIX"
516 )]
517 report_path_prefix: Option<String>,
518
519 #[arg(hide_short_help = true, long, global = true)]
525 fail_on_regression: bool,
526
527 #[arg(hide_short_help = true, long, global = true)]
541 fail_on_stale_baseline: bool,
542
543 #[arg(hide_short_help = true, long, global = true)]
553 fail_on_parse_error: bool,
554
555 #[arg(
561 hide_short_help = true,
562 long,
563 global = true,
564 value_name = "TOLERANCE",
565 default_value = "0"
566 )]
567 tolerance: String,
568
569 #[arg(hide_short_help = true, long, global = true, value_name = "PATH")]
571 regression_baseline: Option<PathBuf>,
572
573 #[expect(
578 clippy::option_option,
579 reason = "clap pattern: None=not passed, Some(None)=flag only (write to config), Some(Some(path))=write to file"
580 )]
581 #[arg(hide_short_help = true, long, global = true, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
582 save_regression_baseline: Option<Option<String>>,
583
584 #[arg(long, value_delimiter = ',')]
586 only: Vec<AnalysisKind>,
587
588 #[arg(long, value_delimiter = ',')]
590 skip: Vec<AnalysisKind>,
591
592 #[arg(hide_short_help = true, long = "dupes-mode", global = true)]
594 dupes_mode: Option<DupesMode>,
595
596 #[arg(hide_short_help = true, long = "dupes-near", global = true)]
598 dupes_near: bool,
599
600 #[arg(hide_short_help = true, long = "dupes-threshold", global = true)]
602 dupes_threshold: Option<f64>,
603
604 #[arg(hide_short_help = true, long = "dupes-min-tokens", global = true)]
606 dupes_min_tokens: Option<usize>,
607
608 #[arg(hide_short_help = true, long = "dupes-min-lines", global = true)]
610 dupes_min_lines: Option<usize>,
611
612 #[arg(hide_short_help = true, long = "dupes-min-occurrences", global = true, value_parser = parse_min_occurrences)]
614 dupes_min_occurrences: Option<usize>,
615
616 #[arg(hide_short_help = true, long = "dupes-skip-local", global = true)]
618 dupes_skip_local: bool,
619
620 #[arg(hide_short_help = true, long = "dupes-cross-language", global = true)]
622 dupes_cross_language: bool,
623
624 #[arg(hide_short_help = true, long = "dupes-ignore-imports", global = true)]
627 dupes_ignore_imports: bool,
628
629 #[arg(
632 hide_short_help = true,
633 long = "dupes-no-ignore-imports",
634 global = true,
635 conflicts_with = "dupes_ignore_imports"
636 )]
637 dupes_no_ignore_imports: bool,
638
639 #[arg(hide_short_help = true, long)]
641 score: bool,
642
643 #[arg(hide_short_help = true, long)]
645 trend: bool,
646
647 #[expect(
651 clippy::option_option,
652 reason = "clap pattern: None=not passed, Some(None)=default path, Some(Some(path))=custom path"
653 )]
654 #[arg(hide_short_help = true, long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
655 save_snapshot: Option<Option<String>>,
656
657 #[arg(hide_short_help = true, long, value_name = "PATH")]
660 coverage: Option<PathBuf>,
661
662 #[arg(hide_short_help = true, long = "coverage-root", value_name = "PATH")]
665 coverage_root: Option<PathBuf>,
666
667 #[arg(hide_short_help = true, long = "dupes-baseline", value_name = "PATH")]
670 dupes_baseline: Option<PathBuf>,
671
672 #[arg(hide_short_help = true, long = "health-baseline", value_name = "PATH")]
675 health_baseline: Option<PathBuf>,
676
677 #[arg(hide_short_help = true, long, global = true)]
679 include_entry_exports: bool,
680
681 #[arg(hide_short_help = true, long, global = true)]
684 type_aware: bool,
685
686 #[arg(
689 hide_short_help = true,
690 long,
691 global = true,
692 conflicts_with = "type_aware"
693 )]
694 no_type_aware: bool,
695
696 #[arg(hide_short_help = true, long, global = true, value_name = "PATH", action = clap::ArgAction::Append)]
698 type_aware_project: Vec<PathBuf>,
699
700 #[arg(hide_short_help = true, long, global = true, value_enum)]
702 type_aware_require: Option<TypeAwareRequireArg>,
703}
704
705impl Cli {
706 const fn type_aware_override(&self) -> Option<bool> {
710 if self.no_type_aware {
711 Some(false)
712 } else if self.type_aware {
713 Some(true)
714 } else {
715 None
716 }
717 }
718}
719
720#[derive(Clone, Copy, Subcommand)]
721enum TypeAwareCli {
722 Status,
724}
725
726#[derive(Subcommand)]
727enum Command {
728 #[command(name = "dead-code", alias = "check")]
730 Check {
731 #[arg(long)]
733 unused_files: bool,
734
735 #[arg(long)]
737 unused_exports: bool,
738
739 #[arg(long)]
741 unused_deps: bool,
742
743 #[arg(long)]
745 unused_types: bool,
746
747 #[arg(long)]
749 private_type_leaks: bool,
750
751 #[arg(long)]
753 deprecated_exports_in_use: bool,
754
755 #[arg(long)]
757 unused_enum_members: bool,
758
759 #[arg(long)]
761 unused_class_members: bool,
762
763 #[arg(long)]
765 unused_store_members: bool,
766
767 #[arg(long)]
769 unprovided_injects: bool,
770
771 #[arg(long)]
773 unrendered_components: bool,
774
775 #[arg(long)]
777 unused_component_props: bool,
778
779 #[arg(long)]
781 unused_component_emits: bool,
782
783 #[arg(long)]
785 unused_component_inputs: bool,
786
787 #[arg(long)]
789 unused_component_outputs: bool,
790
791 #[arg(long)]
793 unused_svelte_events: bool,
794
795 #[arg(long)]
797 unused_server_actions: bool,
798
799 #[arg(long)]
801 unused_load_data_keys: bool,
802
803 #[arg(long)]
805 unresolved_imports: bool,
806
807 #[arg(long)]
809 unlisted_deps: bool,
810
811 #[arg(long)]
813 duplicate_exports: bool,
814
815 #[arg(long)]
817 circular_deps: bool,
818
819 #[arg(long)]
821 re_export_cycles: bool,
822
823 #[arg(long)]
825 boundary_violations: bool,
826
827 #[arg(long)]
829 policy_violations: bool,
830
831 #[arg(long)]
833 stale_suppressions: bool,
834
835 #[arg(long)]
837 unused_catalog_entries: bool,
838
839 #[arg(long)]
841 empty_catalog_groups: bool,
842
843 #[arg(long)]
845 unresolved_catalog_references: bool,
846
847 #[arg(long)]
849 unused_dependency_overrides: bool,
850
851 #[arg(long)]
853 misconfigured_dependency_overrides: bool,
854
855 #[arg(long)]
857 include_dupes: bool,
858
859 #[arg(long, value_name = "FILE:EXPORT")]
861 trace: Option<String>,
862
863 #[arg(long, value_name = "PATH")]
865 trace_file: Option<String>,
866
867 #[arg(long, value_name = "PACKAGE")]
869 trace_dependency: Option<String>,
870
871 #[arg(long, value_name = "PATH")]
875 impact_closure: Option<String>,
876
877 #[arg(long, value_name = "FILE:EXPORT")]
879 symbol_impact: Option<String>,
880
881 #[arg(long)]
886 top: Option<usize>,
887
888 #[arg(long, value_name = "PATH")]
892 file: Vec<std::path::PathBuf>,
893
894 #[arg(value_name = "PATH")]
897 path: Option<std::path::PathBuf>,
898 },
899
900 Watch {
902 #[arg(long)]
904 no_clear: bool,
905 },
906
907 TypeAware {
909 #[command(subcommand)]
910 subcommand: TypeAwareCli,
911 },
912
913 #[command(override_help = doctor::HELP)]
920 Doctor,
921
922 SimilarCode {
928 #[command(subcommand)]
929 subcommand: Option<similar_code_cli::SimilarCodeSubcommand>,
930 #[arg(long, value_name = "0..1")]
932 threshold: Option<f64>,
933 #[arg(long, value_name = "N")]
935 min_lines: Option<usize>,
936 #[arg(long, value_name = "N")]
938 top: Option<usize>,
939 #[arg(long, value_name = "PATH")]
941 file: Vec<PathBuf>,
942
943 #[arg(value_name = "PATH")]
946 path: Option<PathBuf>,
947 },
948
949 Inspect {
951 #[arg(
953 long,
954 value_name = "PATH",
955 conflicts_with = "symbol",
956 required_unless_present = "symbol"
957 )]
958 file: Option<String>,
959
960 #[arg(long, value_name = "FILE:EXPORT", conflicts_with = "file")]
962 symbol: Option<String>,
963
964 #[arg(long)]
969 symbol_chain: bool,
970
971 #[arg(long)]
974 churn: bool,
975 },
976
977 Trace {
992 #[arg(value_name = "FILE:SYMBOL", required_unless_present = "path")]
995 symbol: Option<String>,
996
997 #[arg(
1001 long,
1002 num_args = 2,
1003 value_names = ["FROM", "TO"],
1004 conflicts_with_all = ["symbol", "callers", "callees", "depth"]
1005 )]
1006 path: Vec<String>,
1007
1008 #[arg(long, requires = "path")]
1014 eager_only: bool,
1015
1016 #[arg(long)]
1019 callers: bool,
1020
1021 #[arg(long)]
1024 callees: bool,
1025
1026 #[arg(long, value_name = "N")]
1029 depth: Option<u32>,
1030 },
1031
1032 #[command(name = "trace-error")]
1051 TraceError {
1052 #[arg(value_name = "FILE")]
1056 trace_file: Option<String>,
1057 },
1058
1059 Fix {
1074 #[arg(long)]
1076 dry_run: bool,
1077
1078 #[arg(long, alias = "force")]
1080 yes: bool,
1081
1082 #[arg(long)]
1089 no_create_config: bool,
1090
1091 #[arg(value_name = "PATH")]
1095 path: Option<PathBuf>,
1096 },
1097
1098 Init {
1107 #[arg(long)]
1109 toml: bool,
1110
1111 #[arg(long, conflicts_with_all = ["toml", "hooks", "branch"])]
1113 agents: bool,
1114
1115 #[arg(long)]
1119 hooks: bool,
1120
1121 #[arg(long, requires = "hooks")]
1123 branch: Option<String>,
1124
1125 #[arg(long, conflicts_with_all = ["toml", "agents", "hooks", "branch"])]
1129 decline: bool,
1130 },
1131
1132 Hooks {
1139 #[command(subcommand)]
1140 subcommand: HooksCli,
1141 },
1142
1143 Agent {
1149 #[command(subcommand)]
1150 subcommand: AgentCli,
1151 },
1152
1153 Ci {
1155 #[command(subcommand)]
1156 subcommand: CiCli,
1157 },
1158
1159 ConfigSchema,
1161
1162 PluginSchema,
1164
1165 PluginCheck,
1167
1168 RulePackSchema,
1170
1171 RulePack {
1173 #[command(subcommand)]
1174 subcommand: RulePackCli,
1175 },
1176
1177 Guard {
1179 #[arg(required = true, num_args = 1..)]
1181 files: Vec<String>,
1182 },
1183
1184 Config {
1202 #[arg(long)]
1204 path: bool,
1205 },
1206
1207 Recommend,
1215
1216 List {
1219 #[arg(long)]
1221 entry_points: bool,
1222
1223 #[arg(long)]
1225 files: bool,
1226
1227 #[arg(long)]
1229 plugins: bool,
1230
1231 #[arg(long)]
1233 boundaries: bool,
1234
1235 #[arg(long)]
1239 workspaces: bool,
1240
1241 #[arg(long)]
1251 entry_weight: bool,
1252
1253 #[arg(value_name = "PATH")]
1256 path: Option<PathBuf>,
1257 },
1258
1259 Workspaces,
1265
1266 Dupes {
1268 #[arg(long)]
1271 mode: Option<DupesMode>,
1272
1273 #[arg(long)]
1275 near: bool,
1276
1277 #[arg(long)]
1280 min_tokens: Option<usize>,
1281
1282 #[arg(long)]
1285 min_lines: Option<usize>,
1286
1287 #[arg(long, value_parser = parse_min_occurrences)]
1292 min_occurrences: Option<usize>,
1293
1294 #[arg(long)]
1297 threshold: Option<f64>,
1298
1299 #[arg(long)]
1301 skip_local: bool,
1302
1303 #[arg(long)]
1305 cross_language: bool,
1306
1307 #[arg(long)]
1311 ignore_imports: bool,
1312
1313 #[arg(long, conflicts_with = "ignore_imports")]
1316 no_ignore_imports: bool,
1317
1318 #[arg(long)]
1328 top: Option<usize>,
1329
1330 #[arg(long)]
1334 no_fragments: bool,
1335
1336 #[arg(long, value_name = "FILE:LINE")]
1338 trace: Option<String>,
1339
1340 #[arg(value_name = "PATH")]
1343 path: Option<PathBuf>,
1344 },
1345
1346 Health {
1352 #[arg(long)]
1354 max_cyclomatic: Option<u16>,
1355
1356 #[arg(long)]
1358 max_cognitive: Option<u16>,
1359
1360 #[arg(long)]
1364 max_crap: Option<f64>,
1365
1366 #[arg(long)]
1368 top: Option<usize>,
1369
1370 #[arg(long, default_value = "cyclomatic")]
1372 sort: SortBy,
1373
1374 #[arg(long)]
1377 complexity: bool,
1378
1379 #[arg(long)]
1386 complexity_breakdown: bool,
1387
1388 #[arg(long)]
1393 file_scores: bool,
1394
1395 #[arg(long)]
1398 coverage_gaps: bool,
1399
1400 #[arg(long)]
1403 hotspots: bool,
1404
1405 #[arg(long)]
1409 ownership: bool,
1410
1411 #[arg(long, value_name = "MODE", value_enum)]
1416 ownership_emails: Option<EmailModeArg>,
1417
1418 #[arg(long)]
1421 targets: bool,
1422
1423 #[arg(long)]
1426 type_coupling: bool,
1427
1428 #[arg(long)]
1433 css: bool,
1434
1435 #[arg(long, value_enum)]
1438 effort: Option<EffortFilter>,
1439
1440 #[arg(long)]
1443 score: bool,
1444
1445 #[arg(long, value_name = "N")]
1454 min_score: Option<f64>,
1455
1456 #[arg(long, value_name = "LEVEL", value_enum)]
1462 min_severity: Option<HealthSeverityCli>,
1463
1464 #[arg(long)]
1468 report_only: bool,
1469
1470 #[arg(long, value_name = "DURATION")]
1473 since: Option<String>,
1474
1475 #[arg(long, value_name = "N")]
1477 min_commits: Option<u32>,
1478
1479 #[expect(
1484 clippy::option_option,
1485 reason = "clap pattern: None=not passed, Some(None)=flag only, Some(Some(path))=with value"
1486 )]
1487 #[arg(long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
1488 save_snapshot: Option<Option<String>>,
1489
1490 #[arg(long)]
1494 trend: bool,
1495
1496 #[arg(long, value_name = "PATH")]
1509 coverage: Option<PathBuf>,
1510
1511 #[arg(long, value_name = "PATH")]
1517 coverage_root: Option<PathBuf>,
1518
1519 #[arg(long, value_name = "PATH")]
1525 runtime_coverage: Option<PathBuf>,
1526
1527 #[arg(long, default_value_t = 100)]
1529 min_invocations_hot: u64,
1530
1531 #[arg(long, value_name = "N")]
1537 min_observation_volume: Option<u32>,
1538
1539 #[arg(long, value_name = "RATIO")]
1544 low_traffic_threshold: Option<f64>,
1545
1546 #[arg(value_name = "PATH")]
1549 path: Option<PathBuf>,
1550 },
1551
1552 Flags {
1559 #[arg(long)]
1561 top: Option<usize>,
1562
1563 #[arg(long)]
1566 retirement: bool,
1567
1568 #[arg(long = "reason", value_name = "CODE", requires = "retirement")]
1570 reasons: Vec<flags::RetirementReasonArg>,
1571
1572 #[arg(
1574 long,
1575 value_name = "KEY",
1576 requires = "retirement",
1577 default_value = "age"
1578 )]
1579 sort: flags::RetirementSortArg,
1580
1581 #[arg(
1584 long,
1585 value_name = "MODE",
1586 requires = "retirement",
1587 default_value = "blame"
1588 )]
1589 flag_age: flags::FlagAgeArg,
1590
1591 #[arg(long, value_name = "DAYS", requires = "retirement")]
1593 min_age: Option<u64>,
1594
1595 #[arg(long, value_name = "FILE", requires = "retirement")]
1599 flag_state: Option<std::path::PathBuf>,
1600
1601 #[arg(long, value_name = "DAYS", requires = "retirement")]
1604 max_flag_age: Option<u64>,
1605 },
1606
1607 Suppressions {
1617 #[arg(long, value_name = "PATH")]
1619 file: Vec<std::path::PathBuf>,
1620 },
1621
1622 Explain {
1628 #[arg(required = true, num_args = 1.., value_name = "ISSUE_TYPE")]
1630 issue_type: Vec<String>,
1631 },
1632
1633 #[command(visible_alias = "review")]
1658 Audit {
1659 #[arg(long = "production-dead-code")]
1661 production_dead_code: bool,
1662
1663 #[arg(long = "production-health")]
1665 production_health: bool,
1666
1667 #[arg(long = "production-dupes")]
1669 production_dupes: bool,
1670
1671 #[arg(long)]
1674 dead_code_baseline: Option<PathBuf>,
1675
1676 #[arg(long)]
1679 health_baseline: Option<PathBuf>,
1680
1681 #[arg(long)]
1684 dupes_baseline: Option<PathBuf>,
1685
1686 #[arg(long)]
1690 max_crap: Option<f64>,
1691
1692 #[arg(long, value_name = "PATH")]
1697 coverage: Option<PathBuf>,
1698
1699 #[arg(long, value_name = "PATH")]
1703 coverage_root: Option<PathBuf>,
1704
1705 #[arg(long = "no-css")]
1707 no_css: bool,
1708
1709 #[arg(long)]
1713 css_deep: bool,
1714
1715 #[arg(long = "no-css-deep")]
1717 no_css_deep: bool,
1718
1719 #[arg(long, value_enum)]
1725 gate: Option<AuditGateArg>,
1726
1727 #[arg(long, value_name = "PATH")]
1736 runtime_coverage: Option<PathBuf>,
1737
1738 #[arg(long, default_value_t = 100)]
1741 min_invocations_hot: u64,
1742
1743 #[arg(long, value_name = "MARKER", hide = true)]
1748 gate_marker: Option<String>,
1749
1750 #[arg(long)]
1756 brief: bool,
1757
1758 #[arg(
1763 long,
1764 value_name = "N",
1765 default_value_t = audit_decision_surface::DEFAULT_DECISION_CAP
1766 )]
1767 max_decisions: usize,
1768
1769 #[arg(long, conflicts_with_all = ["walkthrough_file", "walkthrough"])]
1777 walkthrough_guide: bool,
1778
1779 #[arg(long, value_name = "PATH")]
1789 walkthrough_file: Option<PathBuf>,
1790
1791 #[arg(long, conflicts_with_all = ["walkthrough_guide", "walkthrough_file"])]
1797 walkthrough: bool,
1798
1799 #[arg(long, value_name = "PATH")]
1805 mark_viewed: Vec<PathBuf>,
1806
1807 #[arg(long)]
1811 show_cleared: bool,
1812
1813 #[arg(long)]
1819 show_deprioritized: bool,
1820
1821 #[arg(value_name = "PATH")]
1824 path: Option<PathBuf>,
1825 },
1826
1827 AuditCache {
1829 #[command(subcommand)]
1830 subcommand: AuditCacheCli,
1831 },
1832
1833 DecisionSurface {
1848 #[arg(
1851 long,
1852 value_name = "N",
1853 default_value_t = audit_decision_surface::DEFAULT_DECISION_CAP
1854 )]
1855 max_decisions: usize,
1856 },
1857
1858 Impact {
1868 #[command(subcommand)]
1869 subcommand: Option<ImpactCli>,
1870 #[arg(long)]
1874 all: bool,
1875 #[arg(long, value_enum, default_value_t = ImpactSortCli::Recent)]
1877 sort: ImpactSortCli,
1878 #[arg(long)]
1881 limit: Option<usize>,
1882 },
1883
1884 Security {
1915 #[command(subcommand)]
1916 subcommand: Option<SecuritySubcommand>,
1917 #[arg(long, value_name = "PATH")]
1924 runtime_coverage: Option<PathBuf>,
1925 #[arg(long, default_value_t = 100)]
1928 min_invocations_hot: u64,
1929 #[arg(long, value_name = "PATH")]
1933 file: Vec<std::path::PathBuf>,
1934 #[arg(long, value_name = "MODE")]
1940 gate: Option<security::SecurityGateArg>,
1941 #[arg(long)]
1943 surface: bool,
1944
1945 #[arg(value_name = "PATH")]
1948 path: Option<PathBuf>,
1949 },
1950
1951 Report {
1956 #[arg(long, value_name = "PATH")]
1959 from: PathBuf,
1960 },
1961 Schema,
1963
1964 CiTemplate {
1971 #[command(subcommand)]
1972 subcommand: CiTemplateCli,
1973 },
1974
1975 Migrate {
1977 #[arg(long, conflicts_with = "jsonc")]
1979 toml: bool,
1980
1981 #[arg(long)]
1989 jsonc: bool,
1990
1991 #[arg(long)]
1993 dry_run: bool,
1994
1995 #[arg(long, value_name = "PATH")]
1997 from: Option<PathBuf>,
1998 },
1999
2000 License {
2007 #[command(subcommand)]
2008 subcommand: LicenseCli,
2009 },
2010
2011 Telemetry {
2019 #[command(subcommand)]
2020 subcommand: TelemetryCli,
2021 },
2022
2023 Coverage {
2028 #[command(subcommand)]
2029 subcommand: CoverageCli,
2030 },
2031
2032 SetupHooks {
2044 #[arg(long, value_enum)]
2046 agent: Option<setup_hooks::HookAgentArg>,
2047
2048 #[arg(long)]
2050 dry_run: bool,
2051
2052 #[arg(long)]
2055 force: bool,
2056
2057 #[arg(long)]
2059 user: bool,
2060
2061 #[arg(long)]
2063 gitignore_claude: bool,
2064
2065 #[arg(long)]
2069 uninstall: bool,
2070 },
2071
2072 Viz {
2074 #[arg(long = "out", value_name = "PATH")]
2076 output: Option<PathBuf>,
2077
2078 #[arg(long)]
2080 no_open: bool,
2081
2082 #[arg(long = "viz-format", default_value = "html")]
2084 viz_format: viz::VizFormat,
2085 },
2086}
2087
2088#[derive(Subcommand)]
2089enum SecuritySubcommand {
2090 Survivors {
2092 #[arg(long, value_name = "PATH")]
2094 candidates: PathBuf,
2095 #[arg(long, value_name = "PATH")]
2097 verdicts: PathBuf,
2098 #[arg(long)]
2100 require_verdict_for_each_candidate: bool,
2101 },
2102 #[command(name = "blind-spots")]
2104 BlindSpots {
2105 #[arg(long, value_name = "PATH")]
2107 file: Vec<PathBuf>,
2108 },
2109}
2110
2111#[derive(clap::Subcommand)]
2112enum AuditCacheCli {
2113 Remove {
2119 #[arg(long)]
2121 dry_run: bool,
2122
2123 #[arg(long, alias = "force")]
2125 yes: bool,
2126 },
2127
2128 Prune {
2141 #[arg(long)]
2143 dry_run: bool,
2144
2145 #[arg(long, value_name = "N")]
2152 max_age_days: Option<u32>,
2153 },
2154}
2155
2156#[derive(clap::Subcommand)]
2157enum LicenseCli {
2158 Activate {
2163 #[arg(value_name = "JWT")]
2165 jwt: Option<String>,
2166
2167 #[arg(long, value_name = "PATH")]
2169 from_file: Option<PathBuf>,
2170
2171 #[arg(long, conflicts_with_all = ["jwt", "from_file"])]
2173 stdin: bool,
2174
2175 #[arg(long, requires = "email")]
2182 trial: bool,
2183
2184 #[arg(long, value_name = "ADDR")]
2186 email: Option<String>,
2187 },
2188 Status,
2190 Refresh {
2196 #[arg(long, value_name = "KEY")]
2206 api_key: Option<String>,
2207 },
2208 Deactivate,
2210}
2211
2212#[derive(Clone, Copy, clap::Subcommand)]
2213enum TelemetryCli {
2214 Status,
2216 Enable,
2218 Disable,
2220 Inspect {
2222 #[arg(long)]
2224 example: bool,
2225 },
2226}
2227
2228#[derive(clap::Subcommand)]
2229enum CiTemplateCli {
2230 Gitlab {
2232 #[arg(long, value_name = "DIR", num_args = 0..=1, default_missing_value = ".")]
2236 vendor: Option<PathBuf>,
2237
2238 #[arg(long)]
2240 force: bool,
2241 },
2242}
2243
2244#[derive(clap::Subcommand)]
2245enum CoverageCli {
2246 Setup {
2248 #[arg(short = 'y', long)]
2250 yes: bool,
2251
2252 #[arg(long)]
2254 non_interactive: bool,
2255
2256 #[arg(long)]
2258 json: bool,
2259 },
2260 Analyze {
2266 #[arg(long, value_name = "PATH", conflicts_with = "cloud")]
2270 runtime_coverage: Option<PathBuf>,
2271
2272 #[arg(long, visible_alias = "runtime-coverage-cloud")]
2274 cloud: bool,
2275
2276 #[arg(long, value_name = "KEY")]
2278 api_key: Option<String>,
2279
2280 #[arg(long, value_name = "URL")]
2282 api_endpoint: Option<String>,
2283
2284 #[arg(long, value_name = "OWNER/REPO")]
2290 repo: Option<String>,
2291
2292 #[arg(long, value_name = "ID")]
2294 project_id: Option<String>,
2295
2296 #[arg(long, value_name = "DAYS", default_value_t = 30)]
2298 coverage_period: u16,
2299
2300 #[arg(long, value_name = "ENV")]
2302 environment: Option<String>,
2303
2304 #[arg(long, value_name = "SHA")]
2306 commit_sha: Option<String>,
2307
2308 #[arg(long)]
2310 production: bool,
2311
2312 #[arg(long, default_value_t = 100)]
2314 min_invocations_hot: u64,
2315
2316 #[arg(long, value_name = "N")]
2318 min_observation_volume: Option<u32>,
2319
2320 #[arg(long, value_name = "RATIO")]
2322 low_traffic_threshold: Option<f64>,
2323
2324 #[arg(long)]
2326 top: Option<usize>,
2327
2328 #[arg(long)]
2330 blast_radius: bool,
2331
2332 #[arg(long)]
2334 importance: bool,
2335
2336 #[arg(long)]
2338 debug_unmatched: bool,
2339 },
2340 UploadInventory {
2351 #[arg(long, value_name = "KEY")]
2360 api_key: Option<String>,
2361
2362 #[arg(long, value_name = "URL")]
2367 api_endpoint: Option<String>,
2368
2369 #[arg(long, value_name = "PROJECT_ID")]
2374 project_id: Option<String>,
2375
2376 #[arg(long, value_name = "SHA")]
2381 git_sha: Option<String>,
2382
2383 #[arg(long)]
2389 allow_dirty: bool,
2390
2391 #[arg(long, value_name = "GLOB", num_args = 0..)]
2395 exclude_paths: Vec<String>,
2396
2397 #[arg(long, value_name = "PREFIX")]
2410 path_prefix: Option<String>,
2411
2412 #[arg(long)]
2414 dry_run: bool,
2415
2416 #[arg(long)]
2422 with_callers: bool,
2423
2424 #[arg(long)]
2428 ignore_upload_errors: bool,
2429 },
2430 UploadSourceMaps {
2443 #[arg(long, value_name = "PATH", default_value = "dist")]
2445 dir: PathBuf,
2446
2447 #[arg(long, value_name = "GLOB", default_value = "**/*.map")]
2449 include: String,
2450
2451 #[arg(long, value_name = "GLOB", default_value = "**/node_modules/**")]
2455 exclude: Vec<String>,
2456
2457 #[arg(long, value_name = "NAME")]
2461 repo: Option<String>,
2462
2463 #[arg(long, value_name = "SHA")]
2468 git_sha: Option<String>,
2469
2470 #[arg(long, value_name = "URL")]
2472 endpoint: Option<String>,
2473
2474 #[arg(long, value_name = "BOOL", default_value_t = true, action = clap::ArgAction::Set)]
2479 strip_path: bool,
2480
2481 #[arg(long)]
2483 dry_run: bool,
2484
2485 #[arg(long, value_name = "N", default_value_t = 4)]
2487 concurrency: usize,
2488
2489 #[arg(long)]
2491 fail_fast: bool,
2492 },
2493 UploadStaticFindings {
2500 #[arg(long, value_name = "KEY")]
2510 api_key: Option<String>,
2511
2512 #[arg(long, value_name = "URL")]
2517 api_endpoint: Option<String>,
2518
2519 #[arg(long, value_name = "PROJECT_ID")]
2524 project_id: Option<String>,
2525
2526 #[arg(long, value_name = "SHA")]
2531 git_sha: Option<String>,
2532
2533 #[arg(long)]
2539 allow_dirty: bool,
2540
2541 #[arg(long)]
2543 dry_run: bool,
2544
2545 #[arg(long)]
2549 ignore_upload_errors: bool,
2550 },
2551}
2552
2553#[derive(Subcommand)]
2554enum CiCli {
2555 PlanPrComment {
2557 #[arg(long)]
2559 body: PathBuf,
2560
2561 #[arg(long)]
2563 marker_id: String,
2564
2565 #[arg(long)]
2567 clean: bool,
2568
2569 #[arg(long)]
2571 existing_comment_id: Option<String>,
2572
2573 #[arg(long)]
2575 existing_body: Option<PathBuf>,
2576 },
2577
2578 PostPrComment {
2580 #[arg(long, value_enum)]
2582 provider: CiProviderArg,
2583
2584 #[arg(long)]
2586 pr: Option<String>,
2587
2588 #[arg(long)]
2590 mr: Option<String>,
2591
2592 #[arg(long)]
2594 body: PathBuf,
2595
2596 #[arg(long)]
2598 envelope: Option<PathBuf>,
2599
2600 #[arg(long)]
2602 marker_id: String,
2603
2604 #[arg(long)]
2606 clean: bool,
2607
2608 #[arg(long)]
2610 repo: Option<String>,
2611
2612 #[arg(long = "project-id")]
2614 project_id: Option<String>,
2615
2616 #[arg(long = "api-url")]
2618 api_url: Option<String>,
2619
2620 #[arg(long)]
2622 dry_run: bool,
2623 },
2624
2625 PostReview {
2627 #[arg(long, value_enum)]
2629 provider: CiProviderArg,
2630
2631 #[arg(long)]
2633 pr: Option<String>,
2634
2635 #[arg(long)]
2637 mr: Option<String>,
2638
2639 #[arg(long)]
2641 envelope: PathBuf,
2642
2643 #[arg(long)]
2645 repo: Option<String>,
2646
2647 #[arg(long = "project-id")]
2649 project_id: Option<String>,
2650
2651 #[arg(long = "api-url")]
2653 api_url: Option<String>,
2654
2655 #[arg(long)]
2657 dry_run: bool,
2658 },
2659
2660 PostCheckRun {
2662 #[arg(long, value_enum)]
2664 provider: CiProviderArg,
2665
2666 #[arg(long)]
2668 decision: PathBuf,
2669
2670 #[arg(long)]
2672 repo: String,
2673
2674 #[arg(long = "head-sha")]
2676 head_sha: String,
2677
2678 #[arg(long = "api-url")]
2680 api_url: Option<String>,
2681
2682 #[arg(long = "split-gates")]
2684 split_gates: bool,
2685
2686 #[arg(long)]
2688 dry_run: bool,
2689 },
2690
2691 ReconcileReview {
2693 #[arg(long, value_enum)]
2695 provider: CiProviderArg,
2696
2697 #[arg(long)]
2699 pr: Option<String>,
2700
2701 #[arg(long)]
2703 mr: Option<String>,
2704
2705 #[arg(long)]
2707 envelope: PathBuf,
2708
2709 #[arg(long)]
2711 repo: Option<String>,
2712
2713 #[arg(long = "project-id")]
2715 project_id: Option<String>,
2716
2717 #[arg(long = "api-url")]
2719 api_url: Option<String>,
2720
2721 #[arg(long)]
2723 dry_run: bool,
2724 },
2725}
2726
2727#[derive(Subcommand)]
2728enum RulePackCli {
2729 Init {
2731 name: Option<String>,
2733
2734 #[arg(long, default_value = "starter")]
2736 template: String,
2737
2738 #[arg(long, default_value = "rule-packs")]
2740 dir: String,
2741
2742 #[arg(long)]
2744 no_config: bool,
2745 },
2746
2747 List,
2749
2750 Test {
2752 pack: Option<PathBuf>,
2754 },
2755
2756 Schema,
2758}
2759
2760#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
2762pub enum BaselineModeArg {
2763 #[default]
2765 Count,
2766 Identity,
2769}
2770
2771impl From<BaselineModeArg> for fallow_engine::baseline::HealthBaselineMode {
2772 fn from(value: BaselineModeArg) -> Self {
2773 match value {
2774 BaselineModeArg::Count => Self::Count,
2775 BaselineModeArg::Identity => Self::Identity,
2776 }
2777 }
2778}
2779
2780#[derive(Clone, Copy, Debug, clap::ValueEnum)]
2781enum CiProviderArg {
2782 Github,
2783 Gitlab,
2784}
2785
2786#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2788enum TypeAwareRequireArg {
2789 BestEffort,
2791 Complete,
2793}
2794
2795impl From<TypeAwareRequireArg> for fallow_config::TypeAwareRequire {
2796 fn from(value: TypeAwareRequireArg) -> Self {
2797 match value {
2798 TypeAwareRequireArg::BestEffort => Self::BestEffort,
2799 TypeAwareRequireArg::Complete => Self::Complete,
2800 }
2801 }
2802}
2803
2804#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2806pub enum EffortFilter {
2807 Low,
2808 Medium,
2809 High,
2810}
2811
2812impl EffortFilter {
2813 const fn to_estimate(self) -> fallow_output::EffortEstimate {
2815 match self {
2816 Self::Low => fallow_output::EffortEstimate::Low,
2817 Self::Medium => fallow_output::EffortEstimate::Medium,
2818 Self::High => fallow_output::EffortEstimate::High,
2819 }
2820 }
2821}
2822
2823#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2825pub enum HealthSeverityCli {
2826 Moderate,
2827 High,
2828 Critical,
2829}
2830
2831impl HealthSeverityCli {
2832 const fn to_health_severity(self) -> fallow_output::FindingSeverity {
2834 match self {
2835 Self::Moderate => fallow_output::FindingSeverity::Moderate,
2836 Self::High => fallow_output::FindingSeverity::High,
2837 Self::Critical => fallow_output::FindingSeverity::Critical,
2838 }
2839 }
2840}
2841
2842#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2848pub enum EmailModeArg {
2849 Raw,
2851 Handle,
2853 Anonymized,
2855 #[value(hide = true)]
2857 Hash,
2858}
2859
2860impl EmailModeArg {
2861 const fn to_config(self) -> fallow_config::EmailMode {
2863 match self {
2864 Self::Raw => fallow_config::EmailMode::Raw,
2865 Self::Handle => fallow_config::EmailMode::Handle,
2866 Self::Anonymized => fallow_config::EmailMode::Anonymized,
2867 Self::Hash => fallow_config::EmailMode::Hash,
2868 }
2869 }
2870}
2871
2872#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2874pub enum AuditGateArg {
2875 NewOnly,
2877 All,
2879}
2880
2881impl From<AuditGateArg> for fallow_config::AuditGate {
2882 fn from(value: AuditGateArg) -> Self {
2883 match value {
2884 AuditGateArg::NewOnly => Self::NewOnly,
2885 AuditGateArg::All => Self::All,
2886 }
2887 }
2888}
2889
2890fn parse_min_occurrences(s: &str) -> Result<usize, String> {
2894 let value: usize = s
2895 .parse()
2896 .map_err(|_| format!("`{s}` is not a non-negative integer"))?;
2897 if value < 2 {
2898 return Err(format!(
2899 "must be at least 2 (got {value}); a single occurrence isn't a duplicate"
2900 ));
2901 }
2902 Ok(value)
2903}
2904
2905fn resolve_audit_baseline_path(
2911 root: &std::path::Path,
2912 cli: Option<&std::path::Path>,
2913 config: Option<&str>,
2914) -> Option<PathBuf> {
2915 let path = cli.map(std::path::Path::to_path_buf).or_else(|| {
2916 config.map(|p| {
2917 let path = PathBuf::from(p);
2918 if path_util::is_absolute_path_any_platform(&path) {
2919 path
2920 } else {
2921 root.join(path)
2922 }
2923 })
2924 })?;
2925 if path_util::is_absolute_path_any_platform(&path) {
2926 Some(path)
2927 } else {
2928 Some(root.join(path))
2929 }
2930}
2931
2932fn emit_known_failure(
2933 message: &str,
2934 exit_code: u8,
2935 output: fallow_config::OutputFormat,
2936 reason: telemetry::FailureReason,
2937) -> ExitCode {
2938 telemetry::note_failure_reason(reason);
2939 emit_error(message, exit_code, output)
2940}
2941
2942fn emit_known_failure_with_style(
2943 message: &str,
2944 exit_code: u8,
2945 output: fallow_config::OutputFormat,
2946 json_style: json_style::JsonStyle,
2947 reason: telemetry::FailureReason,
2948) -> ExitCode {
2949 telemetry::note_failure_reason(reason);
2950 error::emit_error_with_style(message, exit_code, output, json_style)
2951}
2952
2953fn unsupported_security_global(cli: &Cli) -> Option<&'static str> {
2954 if cli.baseline.is_some() {
2955 Some("--baseline")
2956 } else if cli.save_baseline.is_some() {
2957 Some("--save-baseline")
2958 } else if cli.fail_on_stale_baseline {
2959 Some("--fail-on-stale-baseline")
2960 } else if cli.fail_on_parse_error {
2961 Some("--fail-on-parse-error")
2962 } else if cli.production {
2963 Some("--production")
2964 } else if cli.no_production {
2965 Some("--no-production")
2966 } else if cli.group_by.is_some() {
2967 Some("--group-by")
2968 } else if cli.performance {
2969 Some("--performance")
2970 } else if cli.explain_skipped {
2971 Some("--explain-skipped")
2972 } else if cli.fail_on_regression {
2973 Some("--fail-on-regression")
2974 } else if cli.regression_baseline.is_some() {
2975 Some("--regression-baseline")
2976 } else if cli.save_regression_baseline.is_some() {
2977 Some("--save-regression-baseline")
2978 } else if cli.dupes_mode.is_some() {
2979 Some("--dupes-mode")
2980 } else if cli.dupes_threshold.is_some() {
2981 Some("--dupes-threshold")
2982 } else if cli.dupes_min_tokens.is_some() {
2983 Some("--dupes-min-tokens")
2984 } else if cli.dupes_min_lines.is_some() {
2985 Some("--dupes-min-lines")
2986 } else if cli.dupes_min_occurrences.is_some() {
2987 Some("--dupes-min-occurrences")
2988 } else if cli.dupes_skip_local {
2989 Some("--dupes-skip-local")
2990 } else if cli.dupes_cross_language {
2991 Some("--dupes-cross-language")
2992 } else if cli.dupes_ignore_imports {
2993 Some("--dupes-ignore-imports")
2994 } else if cli.dupes_no_ignore_imports {
2995 Some("--dupes-no-ignore-imports")
2996 } else if cli.include_entry_exports {
2997 Some("--include-entry-exports")
2998 } else {
2999 None
3000 }
3001}
3002
3003struct DispatchContext<'a> {
3004 cli: &'a Cli,
3005 root: &'a std::path::Path,
3006 output: fallow_config::OutputFormat,
3007 quiet: bool,
3008 fail_on_issues: bool,
3009 json_style: json_style::JsonStyle,
3010 threads: usize,
3011 tolerance: regression::Tolerance,
3012 save_regression_file: Option<&'a std::path::PathBuf>,
3013 save_to_config: bool,
3014}
3015
3016impl DispatchContext<'_> {
3017 fn production_modes(
3018 &self,
3019 dead_code: bool,
3020 health: bool,
3021 dupes: bool,
3022 ) -> Result<ProductionModes, ExitCode> {
3023 resolve_production_modes(self.cli, self.root, self.output, dead_code, health, dupes)
3024 }
3025
3026 fn production_for(
3027 &self,
3028 analysis: fallow_config::ProductionAnalysis,
3029 ) -> Result<bool, ExitCode> {
3030 self.production_modes(false, false, false)
3031 .map(|modes| modes.for_analysis(analysis))
3032 }
3033
3034 fn regression_opts(&self, scoped: bool) -> regression::RegressionOpts<'_> {
3035 regression::RegressionOpts {
3036 fail_on_regression: self.cli.fail_on_regression,
3037 tolerance: self.tolerance,
3038 regression_baseline_file: self.cli.regression_baseline.as_deref(),
3039 save_target: if let Some(path) = self.save_regression_file {
3040 regression::SaveRegressionTarget::File(path)
3041 } else if self.save_to_config {
3042 regression::SaveRegressionTarget::Config
3043 } else {
3044 regression::SaveRegressionTarget::None
3045 },
3046 scoped,
3047 quiet: self.quiet,
3048 output: self.output,
3049 }
3050 }
3051}
3052
3053#[cfg(unix)]
3068fn signal_test_helper() -> ExitCode {
3069 use std::io::Write as _;
3070 use std::process::Command;
3071
3072 if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER_GRACEFUL").is_some() {
3073 signal::set_graceful_mode();
3074 }
3075
3076 let mut command = Command::new("sleep");
3077 command.arg("30");
3078 let child = match signal::ScopedChild::spawn(&mut command) {
3079 Ok(c) => c,
3080 Err(err) => {
3081 let _ = writeln!(std::io::stderr(), "spawn sleep failed: {err}");
3082 return ExitCode::from(2);
3083 }
3084 };
3085 let pid = child.id();
3086 let stdout = std::io::stdout();
3087 let mut lock = stdout.lock();
3088 let _ = writeln!(lock, "{pid}");
3089 let _ = lock.flush();
3090 drop(lock);
3091 let _ = child.wait_with_output();
3092 if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER_GRACEFUL").is_some() {
3093 return ExitCode::SUCCESS;
3094 }
3095 std::thread::sleep(std::time::Duration::from_secs(5));
3096 ExitCode::SUCCESS
3097}
3098
3099#[cfg(not(unix))]
3100fn signal_test_helper() -> ExitCode {
3101 ExitCode::from(2)
3102}
3103
3104fn install_spawn_hooks() {
3105 fallow_engine::churn::set_spawn_hook(signal::scoped_child::output);
3106 fallow_engine::changed_files::set_spawn_hook(signal::scoped_child::output);
3107}
3108
3109fn install_signal_handlers() {
3110 if let Err(err) = signal::install_handlers() {
3111 use std::io::Write as _;
3112 let stderr = std::io::stderr();
3113 let mut lock = stderr.lock();
3114 let _ = writeln!(lock, "fallow: failed to install signal handlers: {err}");
3115 }
3116}
3117
3118fn redirect_report_to_file(
3123 path: &std::path::Path,
3124 output: fallow_config::OutputFormat,
3125) -> Result<(), ExitCode> {
3126 match fallow_engine::write_guard::create_file(
3127 path,
3128 fallow_engine::write_guard::WriteTarget::Path,
3129 ) {
3130 Ok(file) => {
3131 report::sink::set_file_sink(file);
3132 colored::control::set_override(false);
3133 Ok(())
3134 }
3135 Err(e) if e.is_directory() => Err(emit_error(
3136 &format!(
3137 "failed to create the directory of {} for --output-file: {e}",
3138 path.display()
3139 ),
3140 2,
3141 output,
3142 )),
3143 Err(e) => Err(emit_error(
3144 &format!("failed to open {} for --output-file: {e}", path.display()),
3145 2,
3146 output,
3147 )),
3148 }
3149}
3150
3151fn finalize_report_file(
3154 path: &std::path::Path,
3155 quiet: bool,
3156 output: fallow_config::OutputFormat,
3157) -> Result<(), ExitCode> {
3158 if let Err(e) = report::sink::flush() {
3159 return Err(emit_error(
3160 &format!("failed to write {}: {e}", path.display()),
3161 2,
3162 output,
3163 ));
3164 }
3165 if !quiet && report::sink::wrote() {
3169 eprintln!("Report written to {}", path.display());
3170 }
3171 Ok(())
3172}
3173
3174pub fn run() -> ExitCode {
3179 process_clock::mark_process_start();
3180 install_signal_handlers();
3181 install_spawn_hooks();
3182
3183 if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER").is_some() {
3184 return signal_test_helper();
3185 }
3186
3187 let (mut cli, fmt) = match parse_cli_args() {
3188 Ok(parsed) => parsed,
3189 Err(code) => return code,
3190 };
3191 if cli.pretty && !fmt.payload_is_json {
3192 eprintln!(
3193 "Error: --pretty requires JSON output. Use --format json --pretty, or remove --pretty."
3194 );
3195 return ExitCode::from(2);
3196 }
3197
3198 if let Some(code) = reject_global_baseline_flags(&cli, &fmt) {
3199 return code;
3200 }
3201
3202 if let Some(code) = run_schema_command_if_requested(&cli, fmt.json_style) {
3203 return code;
3204 }
3205
3206 if let Some(code) = run_telemetry_command_if_requested(&mut cli, fmt.output, fmt.json_style) {
3207 return code;
3208 }
3209 if let Some(code) = run_doctor_command_if_requested(&cli, &fmt) {
3210 return code;
3211 }
3212 if is_impact_statusline(&cli) {
3213 let (root, _) = match validate_inputs(&cli, fmt.output, fmt.json_style) {
3214 Ok(validated) => validated,
3215 Err(code) => return code,
3216 };
3217 return cli_impact::render_impact_statusline(&root);
3218 }
3219 let telemetry_run = start_telemetry_run(&cli, &fmt);
3220
3221 let (root, threads) = match validate_inputs(&cli, fmt.output, fmt.json_style) {
3222 Ok(v) => v,
3223 Err(code) => {
3224 return record_run_epilogue(telemetry_run, code, None, cli.parent_run.as_deref());
3225 }
3226 };
3227
3228 let FormatConfig {
3229 output,
3230 payload_is_json: _,
3231 quiet,
3232 fail_on_issues,
3233 json_style,
3234 } = fmt;
3235
3236 let tolerance =
3237 match run_pre_dispatch_checks(&cli, &root, output, json_style, quiet, telemetry_run) {
3238 Ok(tolerance) => tolerance,
3239 Err(code) => return code,
3240 };
3241
3242 if let Some(note) = write_scope::default_cache_dir_note(&cli, &root) {
3243 eprintln!("{note}");
3244 cli.no_cache = true;
3245 }
3246
3247 let (save_regression_file, save_to_config) = regression_save_targets(&cli);
3248
3249 let command = cli.command.take();
3250 process_clock::record_startup();
3251 let dispatch = DispatchContext {
3252 cli: &cli,
3253 root: &root,
3254 output,
3255 quiet,
3256 fail_on_issues,
3257 json_style,
3258 threads,
3259 tolerance,
3260 save_regression_file: save_regression_file.as_ref(),
3261 save_to_config,
3262 };
3263 let exit_code = match dispatch_and_finalize(&dispatch, command) {
3264 Ok(code) => code,
3265 Err(code) => return code,
3266 };
3267 record_run_epilogue(telemetry_run, exit_code, None, cli.parent_run.as_deref())
3268}
3269
3270#[doc(hidden)]
3274pub fn benchmark_fix_dry_run(root: &Path, threads: usize) -> (ExitCode, usize) {
3275 let config_path = None;
3276 fix::run_fix_with_count(&fix::FixOptions {
3277 root,
3278 config_path: &config_path,
3279 output: fallow_config::OutputFormat::Json,
3280 json_style: json_style::JsonStyle::Compact,
3281 no_cache: true,
3282 threads,
3283 quiet: true,
3284 emit_output: false,
3285 allow_remote_extends: false,
3286 dry_run: true,
3287 yes: false,
3288 production: false,
3289 no_create_config: true,
3290 type_aware: None,
3291 type_aware_projects: &[],
3292 type_aware_require: None,
3293 scope: None,
3294 })
3295}
3296
3297#[doc(hidden)]
3300pub use audit::AuditReviewBenchmarkCorpus;
3301
3302#[doc(hidden)]
3305pub fn create_audit_review_benchmark_corpus(
3306 root: &Path,
3307 changed_files: &[PathBuf],
3308 threads: usize,
3309) -> Result<AuditReviewBenchmarkCorpus, ExitCode> {
3310 audit::create_audit_review_benchmark_corpus(root, changed_files, threads)
3311}
3312
3313#[doc(hidden)]
3316pub fn benchmark_audit_review_brief_many_changed_files_json(
3317 corpus: &mut AuditReviewBenchmarkCorpus,
3318) -> (ExitCode, usize, usize, usize, usize, usize) {
3319 match audit::benchmark_audit_review_brief_many_changed_files_json(corpus) {
3320 Ok(result) => (
3321 ExitCode::SUCCESS,
3322 result.introduced_count,
3323 result.inherited_count,
3324 result.public_api_added_count,
3325 result.decision_count,
3326 result.rendered_bytes,
3327 ),
3328 Err(code) => (code, 0, 0, 0, 0, 0),
3329 }
3330}
3331
3332#[doc(hidden)]
3333pub use inspect::InspectBenchmarkCorpus;
3334
3335#[doc(hidden)]
3338pub fn create_inspect_benchmark_corpus(root: &Path, threads: usize) -> InspectBenchmarkCorpus {
3339 inspect::create_inspect_benchmark_corpus(root, threads)
3340}
3341
3342#[doc(hidden)]
3345pub fn benchmark_inspect_file_evidence_bundle_json(
3346 root: &Path,
3347 threads: usize,
3348 corpus: &InspectBenchmarkCorpus,
3349) -> (ExitCode, usize, usize) {
3350 match inspect::benchmark_inspect_file_evidence_bundle_json(root, threads, corpus) {
3351 Ok((child_call_count, rendered_bytes)) => {
3352 (ExitCode::SUCCESS, child_call_count, rendered_bytes)
3353 }
3354 Err(_) => (ExitCode::from(2), 0, 0),
3355 }
3356}
3357
3358#[doc(hidden)]
3361pub fn benchmark_dead_code_json(root: &Path, threads: usize) -> (ExitCode, usize, usize) {
3362 match check::benchmark_dead_code_json(root, threads) {
3363 Ok((issue_count, rendered_bytes)) => (ExitCode::SUCCESS, issue_count, rendered_bytes),
3364 Err(code) => (code, 0, 0),
3365 }
3366}
3367
3368#[doc(hidden)]
3371pub fn benchmark_security_json(root: &Path, threads: usize) -> (ExitCode, usize, usize) {
3372 match security::benchmark_security_json(root, threads) {
3373 Ok((finding_count, rendered_bytes)) => (ExitCode::SUCCESS, finding_count, rendered_bytes),
3374 Err(code) => (code, 0, 0),
3375 }
3376}
3377
3378#[doc(hidden)]
3379pub use security::{SecurityBlindSpotsBenchmarkResult, SecuritySurvivorsBenchmarkCorpus};
3380
3381#[doc(hidden)]
3384pub fn create_security_survivors_benchmark_corpus(
3385 root: &Path,
3386 threads: usize,
3387) -> Result<SecuritySurvivorsBenchmarkCorpus, ExitCode> {
3388 security::create_security_survivors_benchmark_corpus(root, threads)
3389}
3390
3391#[doc(hidden)]
3394pub fn benchmark_security_survivors_json(
3395 corpus: &SecuritySurvivorsBenchmarkCorpus,
3396) -> (ExitCode, usize, usize, usize, usize, usize) {
3397 match security::benchmark_security_survivors_json(corpus) {
3398 Ok((survivors, dismissed, needs_human_review, unverdicted, rendered_bytes)) => (
3399 ExitCode::SUCCESS,
3400 survivors,
3401 dismissed,
3402 needs_human_review,
3403 unverdicted,
3404 rendered_bytes,
3405 ),
3406 Err(_) => (ExitCode::from(2), 0, 0, 0, 0, 0),
3407 }
3408}
3409
3410#[doc(hidden)]
3413pub fn benchmark_security_blind_spots_json(
3414 root: &Path,
3415 diagnostics: &[fallow_types::results::SecurityUnresolvedCalleeDiagnostic],
3416) -> SecurityBlindSpotsBenchmarkResult {
3417 security::benchmark_security_blind_spots_json(root, diagnostics)
3418}
3419
3420#[doc(hidden)]
3423pub fn benchmark_list_json(root: &Path, threads: usize) -> (ExitCode, usize, usize, usize, usize) {
3424 match list::benchmark_list_json(root, threads) {
3425 Ok((file_count, entry_point_count, workspace_count, rendered_bytes)) => (
3426 ExitCode::SUCCESS,
3427 file_count,
3428 entry_point_count,
3429 workspace_count,
3430 rendered_bytes,
3431 ),
3432 Err(code) => (code, 0, 0, 0, 0),
3433 }
3434}
3435
3436#[doc(hidden)]
3439pub fn benchmark_list_boundaries_json(
3440 root: &Path,
3441 threads: usize,
3442) -> (ExitCode, usize, usize, usize, usize) {
3443 match list::benchmark_list_boundaries_json(root, threads) {
3444 Ok((zone_count, rule_count, matched_file_count, rendered_bytes)) => (
3445 ExitCode::SUCCESS,
3446 zone_count,
3447 rule_count,
3448 matched_file_count,
3449 rendered_bytes,
3450 ),
3451 Err(code) => (code, 0, 0, 0, 0),
3452 }
3453}
3454
3455#[doc(hidden)]
3458pub use watch::WatchFilterBenchmarkGlobalGitignore;
3459
3460#[doc(hidden)]
3463pub fn create_watch_filter_benchmark_global_gitignore() -> WatchFilterBenchmarkGlobalGitignore {
3464 watch::create_benchmark_global_gitignore()
3465}
3466
3467#[doc(hidden)]
3470pub fn benchmark_watch_filter_initialization(
3471 config: &fallow_config::ResolvedConfig,
3472 global_gitignore: &WatchFilterBenchmarkGlobalGitignore,
3473) -> (usize, usize) {
3474 watch::benchmark_filter_initialization(config, global_gitignore)
3475}
3476
3477#[doc(hidden)]
3480pub fn benchmark_viz_html(root: &Path, threads: usize) -> (ExitCode, usize, usize, usize) {
3481 match viz::benchmark_viz_html(root, threads) {
3482 Ok((file_count, edge_count, rendered_bytes)) => {
3483 (ExitCode::SUCCESS, file_count, edge_count, rendered_bytes)
3484 }
3485 Err(code) => (code, 0, 0, 0),
3486 }
3487}
3488
3489#[doc(hidden)]
3492pub fn benchmark_rule_pack_test_json(root: &Path, threads: usize) -> (ExitCode, usize, usize) {
3493 match rule_pack::benchmark_rule_pack_test_json(root, threads) {
3494 Ok((finding_count, rendered_bytes)) => (ExitCode::SUCCESS, finding_count, rendered_bytes),
3495 Err(code) => (code, 0, 0),
3496 }
3497}
3498
3499#[doc(hidden)]
3502pub fn benchmark_recommend_json(root: &Path) -> (ExitCode, usize, usize, bool, usize) {
3503 match onboarding::benchmark_recommend_json(root) {
3504 Ok((decision_count, framework_count, heterogeneous, rendered_bytes)) => (
3505 ExitCode::SUCCESS,
3506 decision_count,
3507 framework_count,
3508 heterogeneous,
3509 rendered_bytes,
3510 ),
3511 Err(_) => (ExitCode::from(2), 0, 0, false, 0),
3512 }
3513}
3514
3515#[doc(hidden)]
3518pub fn benchmark_runtime_coverage_analyze_json(
3519 root: &Path,
3520 runtime_coverage_path: &Path,
3521 response_bytes: &[u8],
3522 threads: usize,
3523) -> (ExitCode, usize, usize, usize, String) {
3524 match coverage::benchmark_local_json(root, runtime_coverage_path, response_bytes, threads) {
3525 Ok((finding_count, hot_path_count, request_bytes, rendered)) => (
3526 ExitCode::SUCCESS,
3527 finding_count,
3528 hot_path_count,
3529 request_bytes,
3530 rendered,
3531 ),
3532 Err(code) => (code, 0, 0, 0, String::new()),
3533 }
3534}
3535
3536fn is_impact_statusline(cli: &Cli) -> bool {
3539 matches!(
3540 cli.command.as_ref(),
3541 Some(Command::Impact {
3542 subcommand: Some(ImpactCli::Statusline),
3543 all: false,
3544 ..
3545 })
3546 )
3547}
3548
3549fn dispatch_and_finalize(
3553 dispatch: &DispatchContext<'_>,
3554 command: Option<Command>,
3555) -> Result<ExitCode, ExitCode> {
3556 let cli = dispatch.cli;
3557 let output = dispatch.output;
3558 let quiet = dispatch.quiet;
3559
3560 if let Some(path) = cli.output_file.as_deref()
3563 && let Err(code) = redirect_report_to_file(path, output)
3564 {
3565 return Err(code);
3566 }
3567
3568 let exit_code = if command.is_some() && cli_has_bare_coverage_input(cli) {
3569 emit_error(bare_coverage_subcommand_error_message(), 2, output)
3570 } else if command.is_some()
3571 && let Some(flag) = cli_bare_combined_baseline_flag(cli)
3572 {
3573 emit_error(
3574 &bare_combined_baseline_subcommand_error_message(flag),
3575 2,
3576 output,
3577 )
3578 } else {
3579 match command {
3580 None => dispatch_bare_command(dispatch),
3581 Some(cmd) => dispatch_subcommand(cmd, dispatch),
3582 }
3583 };
3584
3585 if let Some(path) = cli.output_file.as_deref()
3586 && let Err(code) = finalize_report_file(path, quiet, output)
3587 {
3588 return Err(code);
3589 }
3590 Ok(exit_code)
3591}
3592
3593fn run_telemetry_command_if_requested(
3594 cli: &mut Cli,
3595 output: fallow_config::OutputFormat,
3596 json_style: json_style::JsonStyle,
3597) -> Option<ExitCode> {
3598 if matches!(cli.command, Some(Command::Telemetry { .. }))
3599 && let Some(Command::Telemetry { subcommand }) = cli.command.take()
3600 {
3601 return Some(telemetry::run(
3602 map_telemetry_subcommand(subcommand),
3603 output,
3604 json_style,
3605 ));
3606 }
3607 None
3608}
3609
3610fn run_doctor_command_if_requested(cli: &Cli, format: &FormatConfig) -> Option<ExitCode> {
3615 if !matches!(cli.command, Some(Command::Doctor)) {
3616 return None;
3617 }
3618
3619 if let Some(flag) = unsupported_doctor_option(cli) {
3620 let message = if flag == "--output-file" {
3621 "--output-file is not valid with `fallow doctor`; doctor is read-only and writes its report to stdout".to_string()
3622 } else {
3623 format!("{flag} is not valid with `fallow doctor`.")
3624 };
3625 return Some(crate::error::emit_error_with_style(
3626 &message,
3627 2,
3628 format.output,
3629 format.json_style,
3630 ));
3631 }
3632 if let Err(code) = doctor::validate_output(format.output, format.json_style) {
3633 return Some(code);
3634 }
3635
3636 let root = cli.root.clone().unwrap_or_else(|| {
3637 std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
3638 });
3639 let config_path = cli.config.as_ref().map(|path| {
3640 if path_util::is_absolute_path_any_platform(path) {
3641 path.clone()
3642 } else {
3643 root.join(path)
3644 }
3645 });
3646 let report = doctor::collect_report(&root, config_path.as_deref());
3647 Some(doctor::render_report(
3648 &report,
3649 format.output,
3650 format.json_style,
3651 ))
3652}
3653
3654fn unsupported_doctor_option(cli: &Cli) -> Option<&'static str> {
3658 [
3659 (cli.allow_remote_extends, "--allow-remote-extends"),
3660 (cli.no_cache, "--no-cache"),
3661 (cli.threads.is_some(), "--threads"),
3662 (cli.changed_since.is_some(), "--changed-since"),
3663 (cli.diff_file.is_some(), "--diff-file"),
3664 (cli.diff_stdin, "--diff-stdin"),
3665 (cli.churn_file.is_some(), "--churn-file"),
3666 (cli.max_file_size.is_some(), "--max-file-size"),
3667 (cli.baseline.is_some(), "--baseline"),
3668 (cli.baseline_mode.is_some(), "--baseline-mode"),
3669 (cli.parent_run.is_some(), "--parent-run"),
3670 (cli.save_baseline.is_some(), "--save-baseline"),
3671 (cli.production, "--production"),
3672 (cli.no_production, "--no-production"),
3673 (cli.production_dead_code, "--production-dead-code"),
3674 (cli.production_health, "--production-health"),
3675 (cli.production_dupes, "--production-dupes"),
3676 (cli.workspace.is_some(), "--workspace"),
3677 (cli.changed_workspaces.is_some(), "--changed-workspaces"),
3678 (cli.group_by.is_some(), "--group-by"),
3679 (cli.performance, "--performance"),
3680 (cli.explain, "--explain"),
3681 (cli.explain_skipped, "--explain-skipped"),
3682 (cli.summary, "--summary"),
3683 (cli.ci, "--ci"),
3684 (cli.fail_on_issues, "--fail-on-issues"),
3685 (cli.sarif_file.is_some(), "--sarif-file"),
3686 (cli.output_file.is_some(), "--output-file"),
3687 (cli.report_path_prefix.is_some(), "--report-path-prefix"),
3688 (cli.fail_on_regression, "--fail-on-regression"),
3689 (cli.fail_on_stale_baseline, "--fail-on-stale-baseline"),
3690 (cli.fail_on_parse_error, "--fail-on-parse-error"),
3691 (cli.tolerance != "0", "--tolerance"),
3692 (cli.regression_baseline.is_some(), "--regression-baseline"),
3693 (
3694 cli.save_regression_baseline.is_some(),
3695 "--save-regression-baseline",
3696 ),
3697 (!cli.only.is_empty(), "--only"),
3698 (!cli.skip.is_empty(), "--skip"),
3699 (cli.dupes_mode.is_some(), "--dupes-mode"),
3700 (cli.dupes_near, "--dupes-near"),
3701 (cli.dupes_threshold.is_some(), "--dupes-threshold"),
3702 (cli.dupes_min_tokens.is_some(), "--dupes-min-tokens"),
3703 (cli.dupes_min_lines.is_some(), "--dupes-min-lines"),
3704 (
3705 cli.dupes_min_occurrences.is_some(),
3706 "--dupes-min-occurrences",
3707 ),
3708 (cli.dupes_skip_local, "--dupes-skip-local"),
3709 (cli.dupes_cross_language, "--dupes-cross-language"),
3710 (cli.dupes_ignore_imports, "--dupes-ignore-imports"),
3711 (cli.dupes_no_ignore_imports, "--dupes-no-ignore-imports"),
3712 (cli.score, "--score"),
3713 (cli.trend, "--trend"),
3714 (cli.save_snapshot.is_some(), "--save-snapshot"),
3715 (cli.coverage.is_some(), "--coverage"),
3716 (cli.coverage_root.is_some(), "--coverage-root"),
3717 (cli.dupes_baseline.is_some(), "--dupes-baseline"),
3718 (cli.health_baseline.is_some(), "--health-baseline"),
3719 (cli.include_entry_exports, "--include-entry-exports"),
3720 (cli.type_aware, "--type-aware"),
3721 (cli.no_type_aware, "--no-type-aware"),
3722 (!cli.type_aware_project.is_empty(), "--type-aware-project"),
3723 (cli.type_aware_require.is_some(), "--type-aware-require"),
3724 ]
3725 .into_iter()
3726 .find_map(|(used, flag)| used.then_some(flag))
3727}
3728
3729fn run_schema_command_if_requested(
3730 cli: &Cli,
3731 json_style: json_style::JsonStyle,
3732) -> Option<ExitCode> {
3733 match cli.command {
3734 Some(Command::Schema) => Some(schema::run_schema(json_style)),
3735 Some(Command::ConfigSchema) => Some(init::run_config_schema(json_style)),
3736 Some(Command::PluginSchema) => Some(init::run_plugin_schema(json_style)),
3737 Some(Command::RulePackSchema) => Some(init::run_rule_pack_schema(json_style)),
3738 _ => None,
3739 }
3740}
3741
3742fn regression_save_targets(cli: &Cli) -> (Option<std::path::PathBuf>, bool) {
3743 let save_file = cli.save_regression_baseline.as_ref().and_then(|opt| {
3744 opt.as_ref()
3745 .filter(|path| !path.is_empty())
3746 .map(std::path::PathBuf::from)
3747 });
3748 let save_to_config = cli.save_regression_baseline.is_some() && save_file.is_none();
3749 (save_file, save_to_config)
3750}
3751
3752fn dispatch_bare_command(dispatch: &DispatchContext<'_>) -> ExitCode {
3753 let cli = dispatch.cli;
3754 let (run_check, run_dupes, run_health) = combined::resolve_analyses(&cli.only, &cli.skip);
3755 let production = match dispatch.production_modes(
3756 cli.production_dead_code,
3757 cli.production_health,
3758 cli.production_dupes,
3759 ) {
3760 Ok(production) => production,
3761 Err(code) => return code,
3762 };
3763 let coverage_inputs = if run_health {
3768 match resolve_health_coverage_inputs(
3769 dispatch,
3770 cli.coverage.as_deref(),
3771 cli.coverage_root.as_deref(),
3772 ) {
3773 Ok(inputs) => inputs,
3774 Err(code) => return code,
3775 }
3776 } else {
3777 ResolvedHealthCoverageInputs::default()
3778 };
3779 run_bare_combined(
3780 dispatch,
3781 production,
3782 &coverage_inputs,
3783 BareAnalyses {
3784 run_check,
3785 run_dupes,
3786 run_health,
3787 },
3788 )
3789}
3790
3791#[derive(Clone, Copy)]
3793struct BareAnalyses {
3794 run_check: bool,
3795 run_dupes: bool,
3796 run_health: bool,
3797}
3798
3799fn run_bare_combined(
3802 dispatch: &DispatchContext<'_>,
3803 production: ProductionModes,
3804 coverage_inputs: &ResolvedHealthCoverageInputs,
3805 analyses: BareAnalyses,
3806) -> ExitCode {
3807 let cli = dispatch.cli;
3808 let (output, quiet, fail_on_issues) =
3809 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
3810 if cli.fail_on_parse_error && !analyses.run_check && !analyses.run_health {
3811 return error::emit_error_with_style(
3812 "--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.",
3813 2,
3814 output,
3815 dispatch.json_style,
3816 );
3817 }
3818 let scope = match crate::scope_path::resolve_command_scope(
3819 dispatch.root,
3820 dispatch.output,
3821 cli.path.clone(),
3822 ) {
3823 Ok(scope) => scope.map(|resolved| resolved.absolute),
3824 Err(code) => return code,
3825 };
3826 let scoped_run = scope.is_some();
3827 combined::run_combined(&combined::CombinedOptions {
3828 root: dispatch.root,
3829 config_path: &cli.config,
3830 output,
3831 json_style: dispatch.json_style,
3832 no_cache: cli.no_cache,
3833 threads: dispatch.threads,
3834 quiet,
3835 allow_remote_extends: cli.allow_remote_extends,
3836 fail_on_issues,
3837 sarif_file: cli.sarif_file.as_deref(),
3838 changed_since: cli.changed_since.as_deref(),
3839 churn_file: cli.churn_file.as_deref(),
3840 baseline: cli.baseline.as_deref(),
3841 save_baseline: cli.save_baseline.as_deref(),
3842 dupes_baseline: cli.dupes_baseline.as_deref(),
3843 health_baseline: cli.health_baseline.as_deref(),
3844 health_baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
3845 health_baseline_mode_explicit: cli.baseline_mode.is_some(),
3846 fail_on_stale_baseline: cli.fail_on_stale_baseline,
3847 production: cli.production,
3848 production_dead_code: Some(production.dead_code),
3849 production_health: Some(production.health),
3850 production_dupes: Some(production.dupes),
3851 workspace: cli.workspace.as_deref(),
3852 changed_workspaces: cli.changed_workspaces.as_deref(),
3853 group_by: cli.group_by,
3854 type_aware: cli.type_aware_override(),
3855 type_aware_projects: &cli.type_aware_project,
3856 type_aware_require: cli.type_aware_require.map(Into::into),
3857 explain: cli.explain,
3858 explain_skipped: cli.explain_skipped,
3859 performance: cli.performance,
3860 summary: cli.summary,
3861 run_check: analyses.run_check,
3862 run_dupes: analyses.run_dupes,
3863 run_health: analyses.run_health,
3864 dupes_mode: cli.dupes_mode,
3865 dupes_near: cli.dupes_near,
3866 dupes_threshold: cli.dupes_threshold,
3867 dupes_min_tokens: cli.dupes_min_tokens,
3868 dupes_min_lines: cli.dupes_min_lines,
3869 dupes_min_occurrences: cli.dupes_min_occurrences,
3870 dupes_skip_local: cli.dupes_skip_local,
3871 dupes_cross_language: cli.dupes_cross_language,
3872 dupes_ignore_imports: resolve_ignore_imports(
3873 cli.dupes_ignore_imports,
3874 cli.dupes_no_ignore_imports,
3875 ),
3876 score: cli.score || cli.trend,
3877 trend: cli.trend,
3878 save_snapshot: cli.save_snapshot.as_ref(),
3879 coverage: coverage_inputs.coverage.as_deref(),
3880 coverage_root: coverage_inputs.coverage_root.as_deref(),
3881 include_entry_exports: cli.include_entry_exports,
3882 fail_on_parse_error: cli.fail_on_parse_error,
3883 scope,
3884 regression_opts: dispatch.regression_opts(
3885 cli.changed_since.is_some()
3886 || cli.workspace.is_some()
3887 || cli.changed_workspaces.is_some()
3888 || scoped_run,
3889 ),
3890 })
3891}
3892
3893#[allow(
3894 clippy::too_many_lines,
3895 reason = "the command router is intentionally an exhaustive top-level dispatch table"
3896)]
3897fn dispatch_subcommand(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3898 let cli = dispatch.cli;
3899 let root = dispatch.root;
3900 let output = dispatch.output;
3901 let quiet = dispatch.quiet;
3902 match command {
3903 check @ Command::Check { .. } => dispatch_check_command(check, dispatch),
3904 Command::Watch { no_clear } => dispatch_watch(dispatch, no_clear),
3905 Command::TypeAware { subcommand } => dispatch_type_aware_command(dispatch, subcommand),
3906 Command::Doctor => unreachable!("doctor bypasses the normal dispatch epilogue"),
3907 Command::SimilarCode {
3908 subcommand,
3909 threshold,
3910 min_lines,
3911 top,
3912 file,
3913 path,
3914 } => {
3915 let scope = match crate::scope_path::resolve_command_scope(
3916 dispatch.root,
3917 dispatch.output,
3918 path,
3919 ) {
3920 Ok(scope) => scope,
3921 Err(code) => return code,
3922 };
3923 similar_code_cli::run(similar_code_cli::SimilarCodeCliInput {
3924 root,
3925 config_path: cli.config.as_deref(),
3926 allow_remote_extends: cli.allow_remote_extends,
3927 no_cache: cli.no_cache,
3928 threads: dispatch.threads,
3929 changed_since: cli.changed_since.as_deref(),
3930 diff_file: cli.diff_file.as_deref(),
3931 workspace: cli.workspace.as_deref(),
3932 changed_workspaces: cli.changed_workspaces.as_deref(),
3933 explain: cli.explain,
3934 quiet,
3935 output,
3936 json_style: dispatch.json_style,
3937 threshold,
3938 min_lines,
3939 top,
3940 files: file,
3941 scope,
3942 subcommand,
3943 })
3944 }
3945 Command::Inspect {
3946 file,
3947 symbol,
3948 symbol_chain,
3949 churn,
3950 } => dispatch_inspect_command(dispatch, file, symbol, symbol_chain, churn),
3951 Command::Trace {
3952 symbol,
3953 path,
3954 eager_only,
3955 callers,
3956 callees,
3957 depth,
3958 } => dispatch_trace_command(
3959 dispatch,
3960 symbol,
3961 &path,
3962 eager_only,
3963 TraceChainFlags {
3964 callers,
3965 callees,
3966 depth,
3967 },
3968 ),
3969 Command::TraceError { trace_file } => {
3970 trace_error::run_trace_error(&trace_error::TraceErrorOptions {
3971 root: dispatch.root,
3972 config_path: &dispatch.cli.config,
3973 output: dispatch.output,
3974 json_style: dispatch.json_style,
3975 no_cache: dispatch.cli.no_cache,
3976 threads: dispatch.threads,
3977 quiet: dispatch.quiet,
3978 allow_remote_extends: dispatch.cli.allow_remote_extends,
3979 trace_file: trace_file.as_deref(),
3980 })
3981 }
3982 fix @ Command::Fix { .. } => dispatch_fix_command(&fix, dispatch),
3983 init @ Command::Init { .. } => dispatch_init_command(init, root, quiet),
3984 Command::Hooks { subcommand } => {
3985 run_hooks_command(root, subcommand, output, dispatch.json_style)
3986 }
3987 Command::Agent { subcommand } => dispatch_agent_command(dispatch, subcommand),
3988 Command::Ci { subcommand } => {
3989 ci::run(map_ci_subcommand(subcommand), output, dispatch.json_style)
3990 }
3991 Command::ConfigSchema => init::run_config_schema(dispatch.json_style),
3992 Command::PluginSchema => init::run_plugin_schema(dispatch.json_style),
3993 Command::PluginCheck => plugin_check::run_plugin_check(root, output, dispatch.json_style),
3994 Command::RulePackSchema => init::run_rule_pack_schema(dispatch.json_style),
3995 Command::RulePack { subcommand } => dispatch_rule_pack_command(dispatch, subcommand),
3996 Command::Guard { files } => dispatch_guard_command(dispatch, &files),
3997 Command::CiTemplate { subcommand } => dispatch_ci_template_command(subcommand),
3998 Command::Config { path } => config::run_config_with_options(config::RunConfigInput {
3999 root,
4000 explicit_config: cli.config.as_deref(),
4001 path_only: path,
4002 output,
4003 quiet,
4004 json_style: dispatch.json_style,
4005 load_options: fallow_config::ConfigLoadOptions {
4006 allow_remote_extends: cli.allow_remote_extends,
4007 },
4008 }),
4009 Command::Recommend => onboarding::run_recommend(root, output, dispatch.json_style),
4010 list @ (Command::Workspaces | Command::List { .. }) => {
4011 dispatch_list_command(&list, dispatch)
4012 }
4013 dupes @ Command::Dupes { .. } => dispatch_dupes_command(dupes, dispatch),
4014 health @ Command::Health { .. } => dispatch_health_command(health, dispatch),
4015 Command::Flags {
4016 top,
4017 retirement,
4018 reasons,
4019 sort,
4020 flag_age,
4021 min_age,
4022 flag_state,
4023 max_flag_age,
4024 } => dispatch_flags_command(
4025 dispatch,
4026 top,
4027 retirement.then_some(flags::RetirementArgs {
4028 reasons,
4029 sort,
4030 flag_age,
4031 min_age,
4032 flag_state,
4033 max_flag_age,
4034 }),
4035 ),
4036 Command::Suppressions { file } => dispatch_suppressions_command(dispatch, &file),
4037 Command::Explain { issue_type } => {
4038 explain::run_explain(&issue_type.join(" "), output, dispatch.json_style)
4039 }
4040 audit @ Command::Audit { .. } => dispatch_audit_command(audit, dispatch),
4041 Command::AuditCache { subcommand } => dispatch_audit_cache_command(dispatch, &subcommand),
4042 Command::DecisionSurface { max_decisions } => {
4043 dispatch_decision_surface(dispatch, max_decisions)
4044 }
4045 Command::Impact {
4046 subcommand,
4047 all,
4048 sort,
4049 limit,
4050 } => dispatch_impact(
4051 root,
4052 quiet,
4053 output,
4054 dispatch.json_style,
4055 subcommand,
4056 ImpactCrossRepoOpts { all, sort, limit },
4057 ),
4058 security @ Command::Security { .. } => dispatch_security_command(security, dispatch),
4059 Command::Viz {
4060 output: viz_output,
4061 no_open,
4062 viz_format,
4063 } => dispatch_viz(dispatch, viz_output.as_deref(), no_open, viz_format),
4064 Command::Report { from } => {
4065 cli_report::run_report(&from, output, root, cli.config.as_deref())
4066 }
4067 Command::Schema => unreachable!("handled above"),
4068 migrate @ Command::Migrate { .. } => dispatch_migrate_command(migrate, root),
4069 Command::License { subcommand } => {
4070 dispatch_license_command(subcommand, output, dispatch.json_style)
4071 }
4072 Command::Telemetry { .. } => unreachable!("handled before root validation"),
4073 Command::Coverage { subcommand } => dispatch_coverage_command(dispatch, &subcommand),
4074 setup_hooks @ Command::SetupHooks { .. } => {
4075 dispatch_setup_hooks_command(&setup_hooks, dispatch)
4076 }
4077 }
4078}
4079
4080fn dispatch_type_aware_command(
4081 dispatch: &DispatchContext<'_>,
4082 subcommand: TypeAwareCli,
4083) -> ExitCode {
4084 match subcommand {
4085 TypeAwareCli::Status => {
4086 let status = fallow_api::type_aware_status(dispatch.root);
4087 match dispatch.output {
4088 fallow_config::OutputFormat::Json => {
4089 let output = type_aware_status_output(dispatch.root, status);
4090 match fallow_output::serialize_type_aware_status_json_output(output) {
4091 Ok(value) => match dispatch.json_style.serialize(&value) {
4092 Ok(json) => {
4093 crate::report::sink::outln!("{json}");
4094 ExitCode::SUCCESS
4095 }
4096 Err(error) => emit_error(
4097 &format!("failed to serialize type-aware status: {error}"),
4098 2,
4099 dispatch.output,
4100 ),
4101 },
4102 Err(error) => emit_error(
4103 &format!("failed to build type-aware status: {error}"),
4104 2,
4105 dispatch.output,
4106 ),
4107 }
4108 }
4109 fallow_config::OutputFormat::Human => {
4110 if status.available {
4111 crate::report::sink::outln!(
4112 "{}",
4113 report::human_status_line(
4114 report::HumanStatus::Ok,
4115 format_args!(
4116 "Type-aware companion: available ({}, protocol {}, TypeScript {})",
4117 status.package_version.as_deref().unwrap_or("unknown"),
4118 status.protocol_version,
4119 status.backend_version.as_deref().unwrap_or("unknown"),
4120 )
4121 )
4122 );
4123 } else {
4124 crate::report::sink::outln!(
4125 "{}",
4126 report::human_status_line(
4127 report::HumanStatus::Inactive,
4128 "Type-aware companion: unavailable"
4129 )
4130 );
4131 if let Some(remediation) = status.remediation {
4132 crate::report::sink::outln!(
4133 "{}",
4134 report::human_status_line(
4135 report::HumanStatus::Warning,
4136 format_args!("Action: {remediation}")
4137 )
4138 );
4139 }
4140 }
4141 ExitCode::SUCCESS
4142 }
4143 _ => emit_error(
4144 "type-aware status supports human and json output",
4145 2,
4146 dispatch.output,
4147 ),
4148 }
4149 }
4150 }
4151}
4152
4153fn type_aware_status_output(
4154 root: &Path,
4155 status: fallow_api::TypeAwareStatus,
4156) -> fallow_output::TypeAwareStatusOutput {
4157 let companion_path = status.companion_path.as_deref().map(|path| {
4158 if let Ok(relative) = path.strip_prefix(root)
4159 && !relative.as_os_str().is_empty()
4160 {
4161 relative.to_string_lossy().replace('\\', "/")
4162 } else {
4163 path.file_name()
4164 .unwrap_or(path.as_os_str())
4165 .to_string_lossy()
4166 .into_owned()
4167 }
4168 });
4169 let remediation = status.remediation.map(|message| {
4170 let without_root = message.replace(root.to_string_lossy().as_ref(), ".");
4171 status.companion_path.as_deref().map_or_else(
4172 || without_root.clone(),
4173 |path| {
4174 without_root.replace(
4175 path.to_string_lossy().as_ref(),
4176 companion_path.as_deref().unwrap_or("fallow-type-aware"),
4177 )
4178 },
4179 )
4180 });
4181 fallow_output::TypeAwareStatusOutput {
4182 schema_version: fallow_types::envelope::SchemaVersion(
4183 fallow_output::TYPE_AWARE_STATUS_SCHEMA_VERSION,
4184 ),
4185 version: fallow_types::envelope::ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
4186 available: status.available,
4187 discovery_source: status.discovery_source.map(str::to_string),
4188 companion_path,
4189 package_version: status.package_version,
4190 protocol_version: status.protocol_version,
4191 backend_family: status.backend_family,
4192 backend_version: status.backend_version,
4193 remediation,
4194 }
4195}
4196
4197fn dispatch_check_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4199 let filters = check_issue_filters(&command);
4200 let Command::Check {
4201 include_dupes,
4202 trace,
4203 trace_file,
4204 trace_dependency,
4205 impact_closure,
4206 symbol_impact,
4207 top,
4208 file,
4209 path,
4210 ..
4211 } = command
4212 else {
4213 unreachable!("check dispatcher only handles check commands");
4214 };
4215
4216 let scope = match crate::scope_path::resolve_command_scope(dispatch.root, dispatch.output, path)
4217 {
4218 Ok(scope) => scope.map(|resolved| resolved.absolute),
4219 Err(code) => return code,
4220 };
4221
4222 dispatch_check(
4223 dispatch,
4224 &CheckDispatchArgs {
4225 filters,
4226 trace_opts: TraceOptions {
4227 trace_export: trace,
4228 trace_file,
4229 trace_dependency,
4230 impact_closure,
4231 symbol_impact,
4232 performance: dispatch.cli.performance,
4233 },
4234 include_dupes,
4235 type_aware: dispatch.cli.type_aware_override(),
4236 type_aware_project: dispatch.cli.type_aware_project.clone(),
4237 type_aware_require: dispatch.cli.type_aware_require,
4238 top,
4239 file,
4240 scope,
4241 },
4242 )
4243}
4244
4245fn check_issue_filters(command: &Command) -> IssueFilters {
4250 check_issue_filters_framework(command, &check_issue_filters_core(command))
4251}
4252
4253fn check_issue_filters_core(command: &Command) -> IssueFilters {
4256 let Command::Check {
4257 unused_files,
4258 unused_exports,
4259 unused_deps,
4260 unused_types,
4261 private_type_leaks,
4262 deprecated_exports_in_use,
4263 unused_enum_members,
4264 unused_class_members,
4265 unresolved_imports,
4266 unlisted_deps,
4267 duplicate_exports,
4268 circular_deps,
4269 re_export_cycles,
4270 boundary_violations,
4271 policy_violations,
4272 stale_suppressions,
4273 ..
4274 } = command
4275 else {
4276 unreachable!("check filter builder only handles check commands");
4277 };
4278
4279 let mut filters = IssueFilters::default();
4280 for (flag, active) in [
4281 ("--unused-files", *unused_files),
4282 ("--unused-exports", *unused_exports),
4283 ("--unused-deps", *unused_deps),
4284 ("--unused-types", *unused_types),
4285 ("--private-type-leaks", *private_type_leaks),
4286 ("--deprecated-exports-in-use", *deprecated_exports_in_use),
4287 ("--unused-enum-members", *unused_enum_members),
4288 ("--unused-class-members", *unused_class_members),
4289 ("--unresolved-imports", *unresolved_imports),
4290 ("--unlisted-deps", *unlisted_deps),
4291 ("--duplicate-exports", *duplicate_exports),
4292 ("--circular-deps", *circular_deps),
4293 ("--re-export-cycles", *re_export_cycles),
4294 ("--boundary-violations", *boundary_violations),
4295 ("--policy-violations", *policy_violations),
4296 ("--stale-suppressions", *stale_suppressions),
4297 ] {
4298 enable_check_filter(&mut filters, flag, active);
4299 }
4300 filters
4301}
4302
4303fn check_issue_filters_framework(command: &Command, base: &IssueFilters) -> IssueFilters {
4306 let Command::Check {
4307 unused_store_members,
4308 unprovided_injects,
4309 unrendered_components,
4310 unused_component_props,
4311 unused_component_emits,
4312 unused_component_inputs,
4313 unused_component_outputs,
4314 unused_svelte_events,
4315 unused_server_actions,
4316 unused_load_data_keys,
4317 unused_catalog_entries,
4318 empty_catalog_groups,
4319 unresolved_catalog_references,
4320 unused_dependency_overrides,
4321 misconfigured_dependency_overrides,
4322 ..
4323 } = command
4324 else {
4325 unreachable!("check filter builder only handles check commands");
4326 };
4327
4328 let mut filters = base.clone();
4329 for (flag, active) in [
4330 ("--unused-store-members", *unused_store_members),
4331 ("--unprovided-injects", *unprovided_injects),
4332 ("--unrendered-components", *unrendered_components),
4333 ("--unused-component-props", *unused_component_props),
4334 ("--unused-component-emits", *unused_component_emits),
4335 ("--unused-component-inputs", *unused_component_inputs),
4336 ("--unused-component-outputs", *unused_component_outputs),
4337 ("--unused-svelte-events", *unused_svelte_events),
4338 ("--unused-server-actions", *unused_server_actions),
4339 ("--unused-load-data-keys", *unused_load_data_keys),
4340 ("--unused-catalog-entries", *unused_catalog_entries),
4341 ("--empty-catalog-groups", *empty_catalog_groups),
4342 (
4343 "--unresolved-catalog-references",
4344 *unresolved_catalog_references,
4345 ),
4346 (
4347 "--unused-dependency-overrides",
4348 *unused_dependency_overrides,
4349 ),
4350 (
4351 "--misconfigured-dependency-overrides",
4352 *misconfigured_dependency_overrides,
4353 ),
4354 ] {
4355 enable_check_filter(&mut filters, flag, active);
4356 }
4357 filters
4358}
4359
4360fn enable_check_filter(filters: &mut IssueFilters, flag: &str, active: bool) {
4361 if active {
4362 assert!(
4363 filters.enable_cli_filter_flag(flag),
4364 "check command uses unregistered dead-code filter flag {flag}"
4365 );
4366 }
4367}
4368
4369fn dispatch_inspect_command(
4370 dispatch: &DispatchContext<'_>,
4371 file: Option<String>,
4372 symbol: Option<String>,
4373 symbol_chain: bool,
4374 churn: bool,
4375) -> ExitCode {
4376 let target = match (file, symbol) {
4377 (Some(file), None) => inspect::InspectTarget::File { file },
4378 (None, Some(symbol)) => match selector::parse_file_symbol_selector(&symbol) {
4379 Some((file, export_name)) => inspect::InspectTarget::Symbol {
4380 file: file.to_string(),
4381 export_name: export_name.to_string(),
4382 },
4383 None => {
4384 return emit_error(
4385 "--symbol must be formatted as FILE:EXPORT",
4386 2,
4387 dispatch.output,
4388 );
4389 }
4390 },
4391 _ => {
4392 return emit_error(
4393 "inspect requires exactly one of --file or --symbol",
4394 2,
4395 dispatch.output,
4396 );
4397 }
4398 };
4399
4400 let churn_config = if churn {
4401 match load_config_for_analysis(
4402 dispatch.root,
4403 &dispatch.cli.config,
4404 ConfigLoadOptions {
4405 output: dispatch.output,
4406 no_cache: dispatch.cli.no_cache,
4407 threads: dispatch.threads,
4408 production_override: None,
4409 quiet: dispatch.quiet,
4410 allow_remote_extends: dispatch.cli.allow_remote_extends,
4411 },
4412 fallow_config::ProductionAnalysis::Health,
4413 ) {
4414 Ok(config) => Some(config),
4415 Err(code) => return code,
4416 }
4417 } else {
4418 None
4419 };
4420
4421 inspect::run_inspect(&inspect::InspectOptions {
4422 root: dispatch.root,
4423 config_path: dispatch.cli.config.as_ref(),
4424 output: dispatch.output,
4425 json_style: dispatch.json_style,
4426 no_cache: dispatch.cli.no_cache,
4427 no_production: dispatch.cli.no_production,
4428 max_file_size: dispatch.cli.max_file_size,
4429 threads: dispatch.threads,
4430 quiet: dispatch.quiet,
4431 production: dispatch.cli.production,
4432 workspace: dispatch.cli.workspace.as_ref(),
4433 target,
4434 churn_cache_dir: churn_config
4435 .as_ref()
4436 .map(|config| config.cache_dir.as_path()),
4437 symbol_chain,
4438 type_aware: dispatch.cli.type_aware_override(),
4439 type_aware_projects: &dispatch.cli.type_aware_project,
4440 type_aware_require: dispatch.cli.type_aware_require.map(Into::into),
4441 })
4442}
4443
4444#[derive(Clone, Copy)]
4446struct TraceChainFlags {
4447 callers: bool,
4448 callees: bool,
4449 depth: Option<u32>,
4450}
4451
4452fn dispatch_trace_command(
4453 dispatch: &DispatchContext<'_>,
4454 symbol: Option<String>,
4455 path: &[String],
4456 eager_only: bool,
4457 chain: TraceChainFlags,
4458) -> ExitCode {
4459 let TraceChainFlags {
4460 callers,
4461 callees,
4462 depth,
4463 } = chain;
4464 if let [from, to] = path {
4465 return trace_path::run_trace_path(&trace_path::TracePathOptions {
4466 root: dispatch.root,
4467 config_path: &dispatch.cli.config,
4468 output: dispatch.output,
4469 json_style: dispatch.json_style,
4470 no_cache: dispatch.cli.no_cache,
4471 threads: dispatch.threads,
4472 quiet: dispatch.quiet,
4473 allow_remote_extends: dispatch.cli.allow_remote_extends,
4474 from,
4475 to,
4476 eager_only,
4477 });
4478 }
4479 let Some(symbol) = symbol else {
4480 return emit_error(
4481 "trace requires a FILE:SYMBOL target or --path <FROM> <TO>",
4482 2,
4483 dispatch.output,
4484 );
4485 };
4486 trace_chain::run_trace(&trace_chain::TraceChainOptions {
4487 root: dispatch.root,
4488 config_path: &dispatch.cli.config,
4489 output: dispatch.output,
4490 json_style: dispatch.json_style,
4491 no_cache: dispatch.cli.no_cache,
4492 threads: dispatch.threads,
4493 quiet: dispatch.quiet,
4494 allow_remote_extends: dispatch.cli.allow_remote_extends,
4495 target: symbol,
4496 callers,
4497 callees,
4498 depth: depth.unwrap_or(fallow_types::trace_chain::DEFAULT_TRACE_DEPTH),
4499 })
4500}
4501
4502fn dispatch_security_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4503 let Command::Security {
4504 subcommand,
4505 runtime_coverage,
4506 min_invocations_hot,
4507 file,
4508 gate,
4509 surface,
4510 path,
4511 } = command
4512 else {
4513 unreachable!("security dispatcher only handles security commands");
4514 };
4515
4516 let scope = match crate::scope_path::resolve_command_scope(dispatch.root, dispatch.output, path)
4517 {
4518 Ok(scope) => scope.map(|resolved| resolved.absolute),
4519 Err(code) => return code,
4520 };
4521
4522 let gate = gate.map(security::SecurityGateArg::into_mode);
4523 let cli = dispatch.cli;
4524 let (output, _quiet, fail_on_issues) =
4525 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
4526 let derived_flags = SecurityDerivedFlagState {
4527 output,
4528 json_style: dispatch.json_style,
4529 ci: cli.ci,
4530 fail_on_issues,
4531 sarif_file: cli.sarif_file.as_deref(),
4532 summary: cli.summary,
4533 explain: cli.explain,
4534 runtime_coverage: runtime_coverage.as_deref(),
4535 min_invocations_hot,
4536 file: file.as_slice(),
4537 gate,
4538 surface,
4539 };
4540 if let Some(code) = try_run_security_survivors(subcommand.as_ref(), &derived_flags) {
4541 return code;
4542 }
4543
4544 let scoped_files = scoped_security_files(&file, subcommand.as_ref());
4545 run_security_blind_spots_or_default(
4546 dispatch,
4547 &SecurityRunInputs {
4548 scoped_files: &scoped_files,
4549 subcommand: &subcommand,
4550 runtime_coverage: runtime_coverage.as_deref(),
4551 min_invocations_hot,
4552 gate,
4553 surface,
4554 scope,
4555 },
4556 &derived_flags,
4557 )
4558}
4559
4560struct SecurityRunInputs<'a> {
4563 scoped_files: &'a [PathBuf],
4564 subcommand: &'a Option<SecuritySubcommand>,
4565 runtime_coverage: Option<&'a Path>,
4566 min_invocations_hot: u64,
4567 gate: Option<security::SecurityGateMode>,
4568 surface: bool,
4569 scope: Option<PathBuf>,
4570}
4571
4572fn run_security_blind_spots_or_default(
4574 dispatch: &DispatchContext<'_>,
4575 inputs: &SecurityRunInputs<'_>,
4576 derived_flags: &SecurityDerivedFlagState<'_>,
4577) -> ExitCode {
4578 let cli = dispatch.cli;
4579 let (output, quiet, fail_on_issues) =
4580 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
4581 let opts = security::SecurityOptions {
4582 root: dispatch.root,
4583 config_path: &cli.config,
4584 output,
4585 json_style: dispatch.json_style,
4586 no_cache: cli.no_cache,
4587 threads: dispatch.threads,
4588 quiet,
4589 allow_remote_extends: cli.allow_remote_extends,
4590 fail_on_issues,
4591 sarif_file: cli.sarif_file.as_deref(),
4592 summary: cli.summary,
4593 changed_since: cli.changed_since.as_deref(),
4594 use_shared_diff_index: true,
4595 workspace: cli.workspace.as_deref(),
4596 changed_workspaces: cli.changed_workspaces.as_deref(),
4597 file: inputs.scoped_files,
4598 surface: inputs.surface,
4599 scope: inputs.scope.clone(),
4600 gate: inputs.gate,
4601 runtime_coverage: inputs.runtime_coverage,
4602 min_invocations_hot: inputs.min_invocations_hot,
4603 explain: cli.explain,
4604 };
4605 if matches!(
4606 inputs.subcommand,
4607 Some(SecuritySubcommand::BlindSpots { .. })
4608 ) {
4609 if let Some(code) = validate_security_blind_spots_flags(derived_flags) {
4610 return code;
4611 }
4612 security::run_blind_spots(&opts)
4613 } else {
4614 security::run(&opts)
4615 }
4616}
4617
4618fn try_run_security_survivors(
4621 subcommand: Option<&SecuritySubcommand>,
4622 flags: &SecurityDerivedFlagState<'_>,
4623) -> Option<ExitCode> {
4624 let Some(SecuritySubcommand::Survivors {
4625 candidates,
4626 verdicts,
4627 require_verdict_for_each_candidate,
4628 }) = subcommand
4629 else {
4630 return None;
4631 };
4632 if let Some(code) = validate_security_survivors_flags(flags) {
4633 return Some(code);
4634 }
4635 Some(security::run_survivors(
4636 &security::SecuritySurvivorsOptions {
4637 output: flags.output,
4638 json_style: flags.json_style,
4639 candidates,
4640 verdicts,
4641 require_verdict_for_each_candidate: *require_verdict_for_each_candidate,
4642 },
4643 ))
4644}
4645
4646fn scoped_security_files(
4648 file: &[PathBuf],
4649 subcommand: Option<&SecuritySubcommand>,
4650) -> Vec<PathBuf> {
4651 let mut scoped_files = file.to_vec();
4652 if let Some(SecuritySubcommand::BlindSpots {
4653 file: blind_spot_files,
4654 }) = subcommand
4655 {
4656 scoped_files.extend(blind_spot_files.iter().cloned());
4657 }
4658 scoped_files
4659}
4660
4661struct SecurityDerivedFlagState<'a> {
4662 output: fallow_config::OutputFormat,
4663 json_style: json_style::JsonStyle,
4664 ci: bool,
4665 fail_on_issues: bool,
4666 sarif_file: Option<&'a Path>,
4667 summary: bool,
4668 explain: bool,
4669 runtime_coverage: Option<&'a Path>,
4670 min_invocations_hot: u64,
4671 file: &'a [PathBuf],
4672 gate: Option<security::SecurityGateMode>,
4673 surface: bool,
4674}
4675
4676fn validate_security_survivors_flags(flags: &SecurityDerivedFlagState<'_>) -> Option<ExitCode> {
4677 let flag = if flags.ci {
4678 Some("--ci")
4679 } else if flags.fail_on_issues {
4680 Some("--fail-on-issues")
4681 } else if flags.sarif_file.is_some() {
4682 Some("--sarif-file")
4683 } else if flags.summary {
4684 Some("--summary")
4685 } else if flags.explain {
4686 Some("--explain")
4687 } else if flags.runtime_coverage.is_some() {
4688 Some("--runtime-coverage")
4689 } else if flags.min_invocations_hot != DEFAULT_MIN_INVOCATIONS_HOT {
4690 Some("--min-invocations-hot")
4691 } else if !flags.file.is_empty() {
4692 Some("--file")
4693 } else if flags.gate.is_some() {
4694 Some("--gate")
4695 } else if flags.surface {
4696 Some("--surface")
4697 } else {
4698 None
4699 }?;
4700 Some(emit_error(
4701 &format!("{flag} is not valid with `fallow security survivors`."),
4702 2,
4703 flags.output,
4704 ))
4705}
4706
4707fn validate_security_blind_spots_flags(flags: &SecurityDerivedFlagState<'_>) -> Option<ExitCode> {
4708 let flag = if flags.ci {
4709 Some("--ci")
4710 } else if flags.fail_on_issues {
4711 Some("--fail-on-issues")
4712 } else if flags.sarif_file.is_some() {
4713 Some("--sarif-file")
4714 } else if flags.summary {
4715 Some("--summary")
4716 } else if flags.explain {
4717 Some("--explain")
4718 } else if flags.runtime_coverage.is_some() {
4719 Some("--runtime-coverage")
4720 } else if flags.min_invocations_hot != DEFAULT_MIN_INVOCATIONS_HOT {
4721 Some("--min-invocations-hot")
4722 } else if flags.gate.is_some() {
4723 Some("--gate")
4724 } else if flags.surface {
4725 Some("--surface")
4726 } else {
4727 None
4728 }?;
4729 Some(emit_error(
4730 &format!("{flag} is not valid with `fallow security blind-spots`."),
4731 2,
4732 flags.output,
4733 ))
4734}
4735
4736fn dispatch_dupes_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4737 let Command::Dupes {
4738 mode,
4739 near,
4740 min_tokens,
4741 min_lines,
4742 min_occurrences,
4743 threshold,
4744 skip_local,
4745 cross_language,
4746 ignore_imports,
4747 no_ignore_imports,
4748 top,
4749 no_fragments,
4750 trace,
4751 path,
4752 } = command
4753 else {
4754 unreachable!("dupes dispatcher only handles dupes commands");
4755 };
4756
4757 let scope = match crate::scope_path::resolve_command_scope(dispatch.root, dispatch.output, path)
4758 {
4759 Ok(scope) => scope.map(|resolved| resolved.absolute),
4760 Err(code) => return code,
4761 };
4762
4763 dispatch_dupes(
4764 dispatch,
4765 &DupesDispatchArgs {
4766 mode,
4767 near,
4768 min_tokens,
4769 min_lines,
4770 min_occurrences,
4771 threshold,
4772 skip_local,
4773 cross_language,
4774 ignore_imports,
4775 no_ignore_imports,
4776 top,
4777 no_fragments,
4778 trace,
4779 scope,
4780 },
4781 )
4782}
4783
4784fn dispatch_agent_command(dispatch: &DispatchContext<'_>, subcommand: AgentCli) -> ExitCode {
4785 run_agent_command(
4786 dispatch.root,
4787 dispatch.cli.root.is_some(),
4788 subcommand,
4789 dispatch.output,
4790 dispatch.json_style,
4791 )
4792}
4793
4794fn dispatch_init_command(command: Command, root: &Path, quiet: bool) -> ExitCode {
4795 let Command::Init {
4796 toml,
4797 agents,
4798 hooks,
4799 branch,
4800 decline,
4801 } = command
4802 else {
4803 unreachable!("init dispatcher only handles init commands");
4804 };
4805
4806 init::run_init(&init::InitOptions {
4807 root,
4808 use_toml: toml,
4809 agents,
4810 hooks,
4811 branch: branch.as_deref(),
4812 decline,
4813 quiet,
4814 })
4815}
4816
4817fn dispatch_fix_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4818 let Command::Fix {
4819 dry_run,
4820 yes,
4821 no_create_config,
4822 path,
4823 } = command
4824 else {
4825 unreachable!("fix dispatcher only handles fix commands");
4826 };
4827
4828 let scope = match crate::scope_path::resolve_command_scope(
4829 dispatch.root,
4830 dispatch.output,
4831 path.clone(),
4832 ) {
4833 Ok(scope) => scope.map(|resolved| resolved.absolute),
4834 Err(code) => return code,
4835 };
4836
4837 dispatch_fix(
4838 dispatch,
4839 &FixDispatchArgs {
4840 dry_run: *dry_run,
4841 yes: *yes,
4842 no_create_config: *no_create_config,
4843 scope,
4844 },
4845 )
4846}
4847
4848fn dispatch_list_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4849 match command {
4850 Command::Workspaces => dispatch_list(dispatch, &ListDispatchArgs::workspaces()),
4851 Command::List {
4852 entry_points,
4853 files,
4854 plugins,
4855 boundaries,
4856 workspaces,
4857 entry_weight,
4858 path,
4859 } => {
4860 let scope = match crate::scope_path::resolve_command_scope(
4861 dispatch.root,
4862 dispatch.output,
4863 path.clone(),
4864 ) {
4865 Ok(scope) => scope.map(|resolved| resolved.absolute),
4866 Err(code) => return code,
4867 };
4868 dispatch_list(
4869 dispatch,
4870 &ListDispatchArgs {
4871 entry_points: *entry_points,
4872 files: *files,
4873 plugins: *plugins,
4874 boundaries: *boundaries,
4875 workspaces: *workspaces,
4876 entry_weight: *entry_weight,
4877 scope,
4878 },
4879 )
4880 }
4881 _ => unreachable!("list dispatcher only handles list commands"),
4882 }
4883}
4884
4885fn dispatch_migrate_command(command: Command, root: &Path) -> ExitCode {
4886 let Command::Migrate {
4887 toml,
4888 jsonc,
4889 dry_run,
4890 from,
4891 } = command
4892 else {
4893 unreachable!("migrate dispatcher only handles migrate commands");
4894 };
4895
4896 migrate::run_migrate(root, toml, jsonc, dry_run, from.as_deref())
4897}
4898
4899fn dispatch_license_command(
4900 subcommand: LicenseCli,
4901 output: fallow_config::OutputFormat,
4902 json_style: json_style::JsonStyle,
4903) -> ExitCode {
4904 license::run(&map_license_subcommand(subcommand), output, json_style)
4905}
4906
4907fn dispatch_ci_template_command(subcommand: CiTemplateCli) -> ExitCode {
4908 match subcommand {
4909 CiTemplateCli::Gitlab { vendor, force } => {
4910 ci_template::run_gitlab_template(&ci_template::GitlabTemplateOptions {
4911 vendor_dir: vendor,
4912 force,
4913 })
4914 }
4915 }
4916}
4917
4918fn dispatch_coverage_command(dispatch: &DispatchContext<'_>, subcommand: &CoverageCli) -> ExitCode {
4919 let cli = dispatch.cli;
4920 coverage::run(
4921 map_coverage_subcommand(subcommand, cli.explain),
4922 &coverage::RunContext {
4923 root: dispatch.root,
4924 config_path: &cli.config,
4925 output: dispatch.output,
4926 json_style: dispatch.json_style,
4927 quiet: dispatch.quiet,
4928 no_cache: cli.no_cache,
4929 threads: dispatch.threads,
4930 explain: cli.explain,
4931 allow_remote_extends: cli.allow_remote_extends,
4932 },
4933 )
4934}
4935
4936fn dispatch_health_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4937 let Command::Health {
4938 max_cyclomatic,
4939 max_cognitive,
4940 max_crap,
4941 top,
4942 sort,
4943 complexity,
4944 complexity_breakdown,
4945 file_scores,
4946 coverage_gaps,
4947 hotspots,
4948 ownership,
4949 ownership_emails,
4950 targets,
4951 type_coupling,
4952 css,
4953 effort,
4954 score,
4955 min_score,
4956 min_severity,
4957 report_only,
4958 since,
4959 min_commits,
4960 save_snapshot,
4961 trend,
4962 coverage,
4963 coverage_root,
4964 runtime_coverage,
4965 min_invocations_hot,
4966 min_observation_volume,
4967 low_traffic_threshold,
4968 path,
4969 } = command
4970 else {
4971 unreachable!("health dispatcher only handles health commands");
4972 };
4973
4974 let scope = match crate::scope_path::resolve_command_scope(dispatch.root, dispatch.output, path)
4975 {
4976 Ok(scope) => scope.map(|resolved| resolved.absolute),
4977 Err(code) => return code,
4978 };
4979
4980 let ownership = ownership || ownership_emails.is_some();
4981 let hotspots = hotspots || ownership;
4982 let args = HealthDispatchArgs {
4983 max_cyclomatic,
4984 max_cognitive,
4985 max_crap,
4986 top,
4987 sort,
4988 complexity,
4989 complexity_breakdown,
4990 file_scores,
4991 coverage_gaps,
4992 hotspots,
4993 ownership,
4994 ownership_emails: ownership_emails.map(EmailModeArg::to_config),
4995 targets,
4996 type_coupling,
4997 css,
4998 effort,
4999 score,
5000 min_score,
5001 min_severity: min_severity.map(HealthSeverityCli::to_health_severity),
5002 report_only,
5003 since: since.as_deref(),
5004 min_commits,
5005 save_snapshot: save_snapshot.as_ref(),
5006 trend,
5007 fail_on_stale_baseline: dispatch.cli.fail_on_stale_baseline,
5008 fail_on_parse_error: dispatch.cli.fail_on_parse_error,
5009 coverage: coverage.as_deref(),
5010 coverage_root: coverage_root.as_deref(),
5011 runtime_coverage: runtime_coverage.as_deref(),
5012 min_invocations_hot,
5013 min_observation_volume,
5014 low_traffic_threshold,
5015 scope,
5016 };
5017 dispatch_health(dispatch, &args)
5018}
5019
5020fn dispatch_setup_hooks_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
5021 let Command::SetupHooks {
5022 agent,
5023 dry_run,
5024 force,
5025 user,
5026 gitignore_claude,
5027 uninstall,
5028 } = command
5029 else {
5030 unreachable!("setup-hooks dispatcher only handles setup-hooks commands");
5031 };
5032
5033 eprintln!(
5034 "warning: `fallow setup-hooks` is deprecated and will be removed in the next major; use `fallow agent install` or `fallow hooks install --target agent`."
5035 );
5036 setup_hooks::run_setup_hooks(&setup_hooks::SetupHooksOptions {
5037 root: dispatch.root,
5038 agent: *agent,
5039 dry_run: *dry_run,
5040 force: *force,
5041 user: *user,
5042 gitignore_claude: *gitignore_claude,
5043 uninstall: *uninstall,
5044 })
5045}
5046
5047fn dispatch_audit_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
5048 let Command::Audit {
5049 production_dead_code,
5050 production_health,
5051 production_dupes,
5052 dead_code_baseline,
5053 health_baseline,
5054 dupes_baseline,
5055 max_crap,
5056 coverage,
5057 coverage_root,
5058 no_css,
5059 css_deep,
5060 no_css_deep,
5061 gate,
5062 runtime_coverage,
5063 min_invocations_hot,
5064 gate_marker,
5065 brief,
5066 max_decisions,
5067 walkthrough_guide,
5068 walkthrough_file,
5069 walkthrough,
5070 mark_viewed,
5071 show_cleared,
5072 show_deprioritized,
5073 path,
5074 } = command
5075 else {
5076 unreachable!("audit dispatcher only handles audit commands");
5077 };
5078
5079 let brief = brief || walkthrough_guide || walkthrough || walkthrough_file.is_some();
5082
5083 let scope = match crate::scope_path::resolve_command_scope(dispatch.root, dispatch.output, path)
5084 {
5085 Ok(scope) => scope.map(|resolved| resolved.absolute),
5086 Err(code) => return code,
5087 };
5088
5089 dispatch_audit(
5090 dispatch,
5091 &AuditDispatchArgs {
5092 production_dead_code,
5093 production_health,
5094 production_dupes,
5095 dead_code_baseline,
5096 health_baseline,
5097 dupes_baseline,
5098 max_crap,
5099 coverage,
5100 coverage_root,
5101 no_css,
5102 css_deep,
5103 no_css_deep,
5104 gate,
5105 runtime_coverage,
5106 min_invocations_hot,
5107 gate_marker,
5108 brief,
5109 max_decisions,
5110 walkthrough_guide,
5111 walkthrough_file,
5112 walkthrough,
5113 mark_viewed,
5114 show_cleared,
5115 show_deprioritized,
5116 scope,
5117 },
5118 )
5119}
5120
5121fn dispatch_audit_cache_command(
5122 dispatch: &DispatchContext<'_>,
5123 subcommand: &AuditCacheCli,
5124) -> ExitCode {
5125 match subcommand {
5126 AuditCacheCli::Remove { dry_run, yes } => {
5127 if !*dry_run && !*yes && !std::io::stdin().is_terminal() {
5128 return emit_error(
5129 "audit-cache remove requires --yes (or --force) in non-interactive environments. Use --dry-run to preview removal first, then pass --yes to confirm.",
5130 2,
5131 dispatch.output,
5132 );
5133 }
5134 match base_worktree::remove_reusable_audit_caches(dispatch.root, *dry_run) {
5135 Ok(report) => {
5136 let action = if *dry_run { "would remove" } else { "removed" };
5137 if matches!(dispatch.output, fallow_config::OutputFormat::Json) {
5138 let value = serde_json::json!({
5139 "kind": "audit-cache-remove",
5140 "schema_version": 1,
5141 "command": "audit-cache remove",
5142 "root": dispatch.root,
5143 "dry_run": report.dry_run,
5144 "found": report.found,
5145 "would_remove": report.found.saturating_sub(report.skipped),
5146 "removed": report.removed,
5147 "skipped": report.skipped,
5148 "complete": report.skipped == 0,
5149 });
5150 let output_code = report::emit_report_json(
5151 &value,
5152 "audit cache removal",
5153 dispatch.json_style,
5154 );
5155 if output_code != ExitCode::SUCCESS {
5156 return output_code;
5157 }
5158 } else if !dispatch.quiet {
5159 println!(
5160 "audit cache: {action} {}, skipped {} for {}",
5161 if *dry_run {
5162 report.found.saturating_sub(report.skipped)
5163 } else {
5164 report.removed
5165 },
5166 report.skipped,
5167 dispatch.root.display(),
5168 );
5169 }
5170 if report.skipped == 0 {
5171 ExitCode::SUCCESS
5172 } else {
5173 ExitCode::from(2)
5174 }
5175 }
5176 Err(error) => emit_error(
5177 &format!(
5178 "failed to remove audit caches for {}: {error}",
5179 dispatch.root.display()
5180 ),
5181 2,
5182 dispatch.output,
5183 ),
5184 }
5185 }
5186 AuditCacheCli::Prune {
5187 dry_run,
5188 max_age_days,
5189 } => audit_cache_prune::run_audit_cache_prune(&audit_cache_prune::AuditCachePruneOptions {
5190 root: dispatch.root,
5191 config_path: dispatch.cli.config.as_ref(),
5192 allow_remote_extends: dispatch.cli.allow_remote_extends,
5193 dry_run: *dry_run,
5194 max_age_days: *max_age_days,
5195 output: dispatch.output,
5196 json_style: dispatch.json_style,
5197 quiet: dispatch.quiet,
5198 }),
5199 }
5200}
5201
5202fn dispatch_flags_command(
5203 dispatch: &DispatchContext<'_>,
5204 top: Option<usize>,
5205 retirement: Option<flags::RetirementArgs>,
5206) -> ExitCode {
5207 let cli = dispatch.cli;
5208 let root = dispatch.root;
5209 let output = dispatch.output;
5210 let quiet = dispatch.quiet;
5211 let threads = dispatch.threads;
5212 let production = match resolve_production_modes(cli, root, output, false, false, false) {
5213 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::DeadCode),
5214 Err(code) => return code,
5215 };
5216 flags::run_flags(&flags::FlagsOptions {
5217 root,
5218 config_path: &cli.config,
5219 output,
5220 json_style: dispatch.json_style,
5221 no_cache: cli.no_cache,
5222 threads,
5223 quiet,
5224 allow_remote_extends: cli.allow_remote_extends,
5225 production,
5226 workspace: cli.workspace.as_deref(),
5227 changed_workspaces: cli.changed_workspaces.as_deref(),
5228 changed_since: cli.changed_since.as_deref(),
5229 explain: cli.explain,
5230 top,
5231 retirement,
5232 regression: dispatch.regression_opts(false),
5233 regression_flag: first_regression_flag(cli),
5234 })
5235}
5236
5237fn first_regression_flag(cli: &Cli) -> Option<&'static str> {
5239 [
5240 (cli.fail_on_regression, "--fail-on-regression"),
5241 (cli.regression_baseline.is_some(), "--regression-baseline"),
5242 (
5243 cli.save_regression_baseline.is_some(),
5244 "--save-regression-baseline",
5245 ),
5246 (cli.tolerance != "0", "--tolerance"),
5247 ]
5248 .into_iter()
5249 .find_map(|(used, flag)| used.then_some(flag))
5250}
5251
5252fn dispatch_suppressions_command(
5253 dispatch: &DispatchContext<'_>,
5254 file: &[std::path::PathBuf],
5255) -> ExitCode {
5256 let cli = dispatch.cli;
5257 let root = dispatch.root;
5258 let output = dispatch.output;
5259 let production = match resolve_production_modes(cli, root, output, false, false, false) {
5260 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::DeadCode),
5261 Err(code) => return code,
5262 };
5263 suppressions::run_suppressions(&suppressions::SuppressionsOptions {
5264 root,
5265 config_path: &cli.config,
5266 output,
5267 json_style: dispatch.json_style,
5268 no_cache: cli.no_cache,
5269 threads: dispatch.threads,
5270 quiet: dispatch.quiet,
5271 allow_remote_extends: cli.allow_remote_extends,
5272 production,
5273 workspace: cli.workspace.as_deref(),
5274 changed_workspaces: cli.changed_workspaces.as_deref(),
5275 changed_since: cli.changed_since.as_deref(),
5276 file,
5277 })
5278}
5279
5280fn dispatch_guard_command(dispatch: &DispatchContext<'_>, files: &[String]) -> ExitCode {
5281 guard::run_guard(&guard::GuardOptions {
5282 root: dispatch.root,
5283 config_path: &dispatch.cli.config,
5284 output: dispatch.output,
5285 json_style: dispatch.json_style,
5286 quiet: dispatch.quiet,
5287 allow_remote_extends: dispatch.cli.allow_remote_extends,
5288 files,
5289 })
5290}
5291
5292fn dispatch_rule_pack_command(dispatch: &DispatchContext<'_>, subcommand: RulePackCli) -> ExitCode {
5293 let ctx = rule_pack::RulePackContext {
5294 root: dispatch.root,
5295 config_path: &dispatch.cli.config,
5296 output: dispatch.output,
5297 json_style: dispatch.json_style,
5298 quiet: dispatch.quiet,
5299 no_cache: dispatch.cli.no_cache,
5300 threads: Some(dispatch.threads),
5301 allow_remote_extends: dispatch.cli.allow_remote_extends,
5302 };
5303 rule_pack::run(&map_rule_pack_subcommand(subcommand), &ctx)
5304}
5305
5306fn map_rule_pack_subcommand(subcommand: RulePackCli) -> rule_pack::RulePackSubcommand {
5307 match subcommand {
5308 RulePackCli::Init {
5309 name,
5310 template,
5311 dir,
5312 no_config,
5313 } => rule_pack::RulePackSubcommand::Init(rule_pack::InitArgs {
5314 name,
5315 template,
5316 dir,
5317 no_config,
5318 }),
5319 RulePackCli::List => rule_pack::RulePackSubcommand::List,
5320 RulePackCli::Test { pack } => {
5321 rule_pack::RulePackSubcommand::Test(rule_pack::TestArgs { pack })
5322 }
5323 RulePackCli::Schema => rule_pack::RulePackSubcommand::Schema,
5324 }
5325}
5326
5327fn map_license_subcommand(sub: LicenseCli) -> license::LicenseSubcommand {
5328 match sub {
5329 LicenseCli::Activate {
5330 jwt,
5331 from_file,
5332 stdin,
5333 trial,
5334 email,
5335 } => license::LicenseSubcommand::Activate(license::ActivateArgs {
5336 raw_jwt: jwt,
5337 from_file,
5338 from_stdin: stdin,
5339 trial,
5340 email,
5341 }),
5342 LicenseCli::Status => license::LicenseSubcommand::Status,
5343 LicenseCli::Refresh { api_key } => {
5344 license::LicenseSubcommand::Refresh(license::RefreshArgs { api_key })
5345 }
5346 LicenseCli::Deactivate => license::LicenseSubcommand::Deactivate,
5347 }
5348}
5349
5350fn map_telemetry_subcommand(sub: TelemetryCli) -> telemetry::TelemetryCommand {
5351 match sub {
5352 TelemetryCli::Status => telemetry::TelemetryCommand::Status,
5353 TelemetryCli::Enable => telemetry::TelemetryCommand::Enable,
5354 TelemetryCli::Disable => telemetry::TelemetryCommand::Disable,
5355 TelemetryCli::Inspect { example } => telemetry::TelemetryCommand::Inspect { example },
5356 }
5357}
5358
5359fn map_ci_subcommand(sub: CiCli) -> ci::CiCommand {
5360 match sub {
5361 command @ CiCli::PlanPrComment { .. } => map_ci_plan_pr_comment(command),
5362 command @ CiCli::PostPrComment { .. } => map_ci_post_pr_comment(command),
5363 command @ CiCli::PostReview { .. } => map_ci_post_review(command),
5364 command @ CiCli::PostCheckRun { .. } => map_ci_post_check_run(command),
5365 command @ CiCli::ReconcileReview { .. } => map_ci_reconcile_review(command),
5366 }
5367}
5368
5369fn map_ci_plan_pr_comment(command: CiCli) -> ci::CiCommand {
5370 let CiCli::PlanPrComment {
5371 body,
5372 marker_id,
5373 clean,
5374 existing_comment_id,
5375 existing_body,
5376 } = command
5377 else {
5378 unreachable!("ci plan-pr-comment mapper called with different variant");
5379 };
5380
5381 ci::CiCommand::PlanPrComment {
5382 body,
5383 marker_id,
5384 clean,
5385 existing_comment_id,
5386 existing_body,
5387 }
5388}
5389
5390fn map_ci_post_pr_comment(command: CiCli) -> ci::CiCommand {
5391 let CiCli::PostPrComment {
5392 provider,
5393 pr,
5394 mr,
5395 body,
5396 envelope,
5397 marker_id,
5398 clean,
5399 repo,
5400 project_id,
5401 api_url,
5402 dry_run,
5403 } = command
5404 else {
5405 unreachable!("ci post-pr-comment mapper called with different variant");
5406 };
5407
5408 ci::CiCommand::PostPrComment {
5409 provider: map_ci_provider(provider),
5410 target: pr.or(mr),
5411 body,
5412 envelope,
5413 marker_id,
5414 clean,
5415 repo,
5416 project_id,
5417 api_url,
5418 dry_run,
5419 }
5420}
5421
5422fn map_ci_post_review(command: CiCli) -> ci::CiCommand {
5423 let CiCli::PostReview {
5424 provider,
5425 pr,
5426 mr,
5427 envelope,
5428 repo,
5429 project_id,
5430 api_url,
5431 dry_run,
5432 } = command
5433 else {
5434 unreachable!("ci post-review mapper called with different variant");
5435 };
5436
5437 ci::CiCommand::PostReview {
5438 provider: map_ci_provider(provider),
5439 target: pr.or(mr),
5440 envelope,
5441 repo,
5442 project_id,
5443 api_url,
5444 dry_run,
5445 }
5446}
5447
5448fn map_ci_post_check_run(command: CiCli) -> ci::CiCommand {
5449 let CiCli::PostCheckRun {
5450 provider,
5451 decision,
5452 repo,
5453 head_sha,
5454 api_url,
5455 split_gates,
5456 dry_run,
5457 } = command
5458 else {
5459 unreachable!("ci post-check-run mapper called with different variant");
5460 };
5461
5462 ci::CiCommand::PostCheckRun {
5463 provider: map_ci_provider(provider),
5464 decision,
5465 repo,
5466 head_sha,
5467 api_url,
5468 split_gates,
5469 dry_run,
5470 }
5471}
5472
5473fn map_ci_reconcile_review(command: CiCli) -> ci::CiCommand {
5474 let CiCli::ReconcileReview {
5475 provider,
5476 pr,
5477 mr,
5478 envelope,
5479 repo,
5480 project_id,
5481 api_url,
5482 dry_run,
5483 } = command
5484 else {
5485 unreachable!("ci reconcile-review mapper called with different variant");
5486 };
5487
5488 ci::CiCommand::ReconcileReview {
5489 provider: map_ci_provider(provider),
5490 target: pr.or(mr),
5491 envelope,
5492 repo,
5493 project_id,
5494 api_url,
5495 dry_run,
5496 }
5497}
5498
5499fn map_ci_provider(provider: CiProviderArg) -> ci::CiProvider {
5500 match provider {
5501 CiProviderArg::Github => ci::CiProvider::Github,
5502 CiProviderArg::Gitlab => ci::CiProvider::Gitlab,
5503 }
5504}
5505
5506fn map_coverage_subcommand(sub: &CoverageCli, explain: bool) -> coverage::CoverageSubcommand {
5507 match sub {
5508 CoverageCli::Setup {
5509 yes,
5510 non_interactive,
5511 json,
5512 } => map_coverage_setup(*yes, *non_interactive, *json, explain),
5513 CoverageCli::Analyze { .. } => map_coverage_analyze(sub),
5514 CoverageCli::UploadInventory { .. } => map_coverage_upload_inventory(sub),
5515 CoverageCli::UploadSourceMaps { .. } => map_coverage_upload_source_maps(sub),
5516 CoverageCli::UploadStaticFindings { .. } => map_coverage_upload_static_findings(sub),
5517 }
5518}
5519
5520fn map_coverage_setup(
5521 yes: bool,
5522 non_interactive: bool,
5523 json: bool,
5524 explain: bool,
5525) -> coverage::CoverageSubcommand {
5526 coverage::CoverageSubcommand::Setup(coverage::SetupArgs {
5527 yes,
5528 non_interactive: non_interactive || json,
5529 json,
5530 explain,
5531 })
5532}
5533
5534fn map_coverage_analyze(sub: &CoverageCli) -> coverage::CoverageSubcommand {
5535 let CoverageCli::Analyze {
5536 runtime_coverage,
5537 cloud,
5538 api_key,
5539 api_endpoint,
5540 repo,
5541 project_id,
5542 coverage_period,
5543 environment,
5544 commit_sha,
5545 production,
5546 min_invocations_hot,
5547 min_observation_volume,
5548 low_traffic_threshold,
5549 top,
5550 blast_radius,
5551 importance,
5552 debug_unmatched,
5553 } = sub
5554 else {
5555 unreachable!("coverage analyze mapper called with non-analyze variant");
5556 };
5557 coverage::CoverageSubcommand::Analyze(coverage::AnalyzeArgs {
5558 runtime_coverage: runtime_coverage.clone(),
5559 cloud: *cloud,
5560 api_key: api_key.clone(),
5561 api_endpoint: api_endpoint.clone(),
5562 repo: repo.clone(),
5563 project_id: project_id.clone(),
5564 coverage_period: *coverage_period,
5565 environment: environment.clone(),
5566 commit_sha: commit_sha.clone(),
5567 production: *production,
5568 min_invocations_hot: *min_invocations_hot,
5569 min_observation_volume: *min_observation_volume,
5570 low_traffic_threshold: *low_traffic_threshold,
5571 top: *top,
5572 blast_radius: *blast_radius,
5573 importance: *importance,
5574 debug_unmatched: *debug_unmatched,
5575 })
5576}
5577
5578fn map_coverage_upload_inventory(sub: &CoverageCli) -> coverage::CoverageSubcommand {
5579 let CoverageCli::UploadInventory {
5580 api_key,
5581 api_endpoint,
5582 project_id,
5583 git_sha,
5584 allow_dirty,
5585 exclude_paths,
5586 path_prefix,
5587 dry_run,
5588 with_callers,
5589 ignore_upload_errors,
5590 } = sub
5591 else {
5592 unreachable!("coverage inventory mapper called with non-inventory variant");
5593 };
5594 coverage::CoverageSubcommand::UploadInventory(coverage::UploadInventoryArgs {
5595 api_key: api_key.clone(),
5596 api_endpoint: api_endpoint.clone(),
5597 project_id: project_id.clone(),
5598 git_sha: git_sha.clone(),
5599 allow_dirty: *allow_dirty,
5600 exclude_paths: exclude_paths.clone(),
5601 path_prefix: path_prefix.clone(),
5602 dry_run: *dry_run,
5603 with_callers: *with_callers,
5604 ignore_upload_errors: *ignore_upload_errors,
5605 })
5606}
5607
5608fn map_coverage_upload_source_maps(sub: &CoverageCli) -> coverage::CoverageSubcommand {
5609 let CoverageCli::UploadSourceMaps {
5610 dir,
5611 include,
5612 exclude,
5613 repo,
5614 git_sha,
5615 endpoint,
5616 strip_path,
5617 dry_run,
5618 concurrency,
5619 fail_fast,
5620 } = sub
5621 else {
5622 unreachable!("coverage source-map mapper called with non-source-map variant");
5623 };
5624 coverage::CoverageSubcommand::UploadSourceMaps(coverage::UploadSourceMapsArgs {
5625 dir: dir.clone(),
5626 include: include.clone(),
5627 exclude: exclude.clone(),
5628 repo: repo.clone(),
5629 git_sha: git_sha.clone(),
5630 endpoint: endpoint.clone(),
5631 strip_path: *strip_path,
5632 dry_run: *dry_run,
5633 concurrency: *concurrency,
5634 fail_fast: *fail_fast,
5635 })
5636}
5637
5638fn map_coverage_upload_static_findings(sub: &CoverageCli) -> coverage::CoverageSubcommand {
5639 let CoverageCli::UploadStaticFindings {
5640 api_key,
5641 api_endpoint,
5642 project_id,
5643 git_sha,
5644 allow_dirty,
5645 dry_run,
5646 ignore_upload_errors,
5647 } = sub
5648 else {
5649 unreachable!("coverage static-findings mapper called with non-static variant");
5650 };
5651 coverage::CoverageSubcommand::UploadStaticFindings(coverage::UploadStaticFindingsArgs {
5652 api_key: api_key.clone(),
5653 api_endpoint: api_endpoint.clone(),
5654 project_id: project_id.clone(),
5655 git_sha: git_sha.clone(),
5656 allow_dirty: *allow_dirty,
5657 dry_run: *dry_run,
5658 ignore_upload_errors: *ignore_upload_errors,
5659 })
5660}
5661
5662struct CheckDispatchArgs {
5663 filters: IssueFilters,
5664 trace_opts: TraceOptions,
5665 include_dupes: bool,
5666 type_aware: Option<bool>,
5667 type_aware_project: Vec<std::path::PathBuf>,
5668 type_aware_require: Option<TypeAwareRequireArg>,
5669 top: Option<usize>,
5670 file: Vec<std::path::PathBuf>,
5671 scope: Option<std::path::PathBuf>,
5672}
5673
5674#[derive(Clone)]
5675struct ListDispatchArgs {
5676 entry_points: bool,
5677 files: bool,
5678 plugins: bool,
5679 boundaries: bool,
5680 workspaces: bool,
5681 entry_weight: bool,
5682 scope: Option<std::path::PathBuf>,
5683}
5684
5685impl ListDispatchArgs {
5686 fn workspaces() -> Self {
5687 Self {
5688 entry_points: false,
5689 files: false,
5690 plugins: false,
5691 boundaries: false,
5692 workspaces: true,
5693 entry_weight: false,
5694 scope: None,
5695 }
5696 }
5697}
5698
5699fn dispatch_viz(
5700 dispatch: &DispatchContext<'_>,
5701 output_path: Option<&std::path::Path>,
5702 no_open: bool,
5703 format: viz::VizFormat,
5704) -> ExitCode {
5705 let cli = dispatch.cli;
5706 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
5707 Ok(production) => production,
5708 Err(code) => return code,
5709 };
5710 viz::run_viz(&viz::VizOptions {
5711 root: dispatch.root,
5712 config_path: &cli.config,
5713 no_cache: cli.no_cache,
5714 threads: dispatch.threads,
5715 quiet: dispatch.quiet,
5716 production,
5717 allow_remote_extends: cli.allow_remote_extends,
5718 output_path,
5719 no_open,
5720 format,
5721 })
5722}
5723
5724fn dispatch_watch(dispatch: &DispatchContext<'_>, no_clear: bool) -> ExitCode {
5725 let cli = dispatch.cli;
5726 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
5727 Ok(production) => production,
5728 Err(code) => return code,
5729 };
5730 watch::run_watch(&watch::WatchOptions {
5731 root: dispatch.root,
5732 config_path: &cli.config,
5733 output: dispatch.output,
5734 json_style: dispatch.json_style,
5735 no_cache: cli.no_cache,
5736 threads: dispatch.threads,
5737 quiet: dispatch.quiet,
5738 allow_remote_extends: cli.allow_remote_extends,
5739 production,
5740 clear_screen: !no_clear,
5741 explain: cli.explain,
5742 include_entry_exports: cli.include_entry_exports,
5743 type_aware: cli.type_aware_override(),
5744 type_aware_projects: &cli.type_aware_project,
5745 type_aware_require: cli.type_aware_require.map(Into::into),
5746 })
5747}
5748
5749struct FixDispatchArgs {
5750 dry_run: bool,
5751 yes: bool,
5752 no_create_config: bool,
5753 scope: Option<std::path::PathBuf>,
5754}
5755
5756fn dispatch_fix(dispatch: &DispatchContext<'_>, args: &FixDispatchArgs) -> ExitCode {
5757 let cli = dispatch.cli;
5758 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
5759 Ok(production) => production,
5760 Err(code) => return code,
5761 };
5762 fix::run_fix(&fix::FixOptions {
5763 root: dispatch.root,
5764 config_path: &cli.config,
5765 output: dispatch.output,
5766 json_style: dispatch.json_style,
5767 no_cache: cli.no_cache,
5768 threads: dispatch.threads,
5769 quiet: dispatch.quiet,
5770 emit_output: true,
5771 allow_remote_extends: cli.allow_remote_extends,
5772 dry_run: args.dry_run,
5773 yes: args.yes,
5774 production,
5775 no_create_config: args.no_create_config,
5776 type_aware: cli.type_aware_override(),
5777 type_aware_projects: &cli.type_aware_project,
5778 type_aware_require: cli.type_aware_require.map(Into::into),
5779 scope: args.scope.clone(),
5780 })
5781}
5782
5783fn dispatch_list(dispatch: &DispatchContext<'_>, args: &ListDispatchArgs) -> ExitCode {
5784 let cli = dispatch.cli;
5785 let (save_regression_file, save_to_config) = regression_save_targets(cli);
5786 let entry_weight_gate = if args.entry_weight {
5789 let tolerance = match regression::Tolerance::parse(&cli.tolerance) {
5790 Ok(tolerance) => tolerance,
5791 Err(message) => {
5792 return emit_error(
5793 &format!("invalid --tolerance: {message}"),
5794 2,
5795 dispatch.output,
5796 );
5797 }
5798 };
5799 Some(regression::EntryWeightGate {
5800 fail_on_regression: cli.fail_on_regression,
5801 tolerance,
5802 baseline_file: cli.regression_baseline.as_deref(),
5803 save_file: save_regression_file.as_deref(),
5804 save_to_config,
5805 })
5806 } else {
5807 None
5808 };
5809 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
5810 Ok(production) => production,
5811 Err(code) => return code,
5812 };
5813 list::run_list(&ListOptions {
5814 root: dispatch.root,
5815 config_path: &cli.config,
5816 output: dispatch.output,
5817 json_style: dispatch.json_style,
5818 threads: dispatch.threads,
5819 no_cache: cli.no_cache,
5820 entry_points: args.entry_points,
5821 files: args.files,
5822 plugins: args.plugins,
5823 boundaries: args.boundaries,
5824 workspaces: args.workspaces,
5825 entry_weight: args.entry_weight,
5826 entry_weight_gate,
5827 production,
5828 allow_remote_extends: cli.allow_remote_extends,
5829 scope: args.scope.clone(),
5830 })
5831}
5832
5833fn dispatch_check(dispatch: &DispatchContext<'_>, args: &CheckDispatchArgs) -> ExitCode {
5834 let cli = dispatch.cli;
5835 let (output, quiet, fail_on_issues) =
5836 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
5837 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
5838 Ok(production) => production,
5839 Err(code) => return code,
5840 };
5841 if let Some(code) = validate_type_aware_check_options(dispatch, args) {
5842 return code;
5843 }
5844 check::run_check(&CheckOptions {
5845 root: dispatch.root,
5846 config_path: &cli.config,
5847 output,
5848 json_style: dispatch.json_style,
5849 no_cache: cli.no_cache,
5850 threads: dispatch.threads,
5851 quiet,
5852 allow_remote_extends: cli.allow_remote_extends,
5853 fail_on_issues,
5854 filters: &args.filters,
5855 changed_since: cli.changed_since.as_deref(),
5856 diff_index: None,
5857 use_shared_diff_index: true,
5858 baseline: cli.baseline.as_deref(),
5859 baseline_flag: "--baseline",
5860 save_baseline: cli.save_baseline.as_deref(),
5861 fail_on_stale_baseline: cli.fail_on_stale_baseline,
5862 sarif_file: cli.sarif_file.as_deref(),
5863 production,
5864 production_override: Some(production),
5865 workspace: cli.workspace.as_deref(),
5866 changed_workspaces: cli.changed_workspaces.as_deref(),
5867 group_by: cli.group_by,
5868 include_dupes: args.include_dupes,
5869 type_aware: args.type_aware,
5870 type_aware_config_override: None,
5871 type_aware_projects: &args.type_aware_project,
5872 type_aware_require: args.type_aware_require.map(Into::into),
5873 trace_opts: &args.trace_opts,
5874 explain: cli.explain,
5875 top: args.top,
5876 file: &args.file,
5877 scope: args.scope.clone(),
5878 include_entry_exports: cli.include_entry_exports,
5879 fail_on_parse_error: cli.fail_on_parse_error,
5880 summary: cli.summary,
5881 regression_opts: dispatch.regression_opts(
5882 cli.changed_since.is_some()
5883 || cli.workspace.is_some()
5884 || cli.changed_workspaces.is_some()
5885 || !args.file.is_empty()
5886 || args.scope.is_some(),
5887 ),
5888 retain_modules_for_health: false,
5889 defer_performance: true,
5890 analysis_snapshot: fallow_config::AnalysisSnapshot::Current,
5891 explain_skipped: cli.explain_skipped,
5892 })
5893}
5894
5895fn validate_type_aware_check_options(
5896 dispatch: &DispatchContext<'_>,
5897 args: &CheckDispatchArgs,
5898) -> Option<ExitCode> {
5899 let output = dispatch.output;
5900 if !args.type_aware_project.is_empty() && args.type_aware != Some(true) {
5901 return Some(emit_error(
5902 "--type-aware-project requires --type-aware",
5903 2,
5904 output,
5905 ));
5906 }
5907 if args.type_aware_require.is_some() && args.type_aware != Some(true) {
5908 return Some(emit_error(
5909 "--type-aware-require requires --type-aware",
5910 2,
5911 output,
5912 ));
5913 }
5914 if args.trace_opts.symbol_impact.is_some() && args.type_aware != Some(true) {
5915 return Some(emit_error(
5916 "--symbol-impact requires --type-aware",
5917 2,
5918 output,
5919 ));
5920 }
5921 let focused_output = args.trace_opts.trace_export.is_some()
5922 || args.trace_opts.trace_file.is_some()
5923 || args.trace_opts.trace_dependency.is_some()
5924 || args.trace_opts.impact_closure.is_some()
5925 || args.trace_opts.symbol_impact.is_some();
5926 if focused_output
5927 && !matches!(
5928 output,
5929 fallow_config::OutputFormat::Human | fallow_config::OutputFormat::Json
5930 )
5931 {
5932 return Some(emit_error(
5933 "focused trace and impact queries support human and JSON output",
5934 2,
5935 output,
5936 ));
5937 }
5938 if args.type_aware == Some(true)
5939 && !matches!(
5940 output,
5941 fallow_config::OutputFormat::Human
5942 | fallow_config::OutputFormat::Json
5943 | fallow_config::OutputFormat::Sarif
5944 | fallow_config::OutputFormat::Compact
5945 | fallow_config::OutputFormat::Markdown
5946 | fallow_config::OutputFormat::CodeClimate
5947 | fallow_config::OutputFormat::PrCommentGithub
5948 | fallow_config::OutputFormat::PrCommentGitlab
5949 | fallow_config::OutputFormat::ReviewGithub
5950 | fallow_config::OutputFormat::ReviewGitlab
5951 )
5952 {
5953 return Some(emit_error(
5954 "--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",
5955 2,
5956 output,
5957 ));
5958 }
5959 None
5960}
5961
5962fn resolve_ignore_imports(ignore_imports: bool, no_ignore_imports: bool) -> Option<bool> {
5968 if no_ignore_imports {
5969 Some(false)
5970 } else if ignore_imports {
5971 Some(true)
5972 } else {
5973 None
5974 }
5975}
5976
5977struct DupesDispatchArgs {
5978 mode: Option<DupesMode>,
5979 near: bool,
5980 min_tokens: Option<usize>,
5981 min_lines: Option<usize>,
5982 min_occurrences: Option<usize>,
5983 threshold: Option<f64>,
5984 skip_local: bool,
5985 cross_language: bool,
5986 ignore_imports: bool,
5987 no_ignore_imports: bool,
5988 top: Option<usize>,
5989 no_fragments: bool,
5990 trace: Option<String>,
5991 scope: Option<std::path::PathBuf>,
5992}
5993
5994fn dispatch_dupes(dispatch: &DispatchContext<'_>, args: &DupesDispatchArgs) -> ExitCode {
5995 let cli = dispatch.cli;
5996 let (output, quiet, _fail_on_issues) =
5997 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
5998 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::Dupes) {
5999 Ok(production) => production,
6000 Err(code) => return code,
6001 };
6002 dupes::run_dupes(&DupesOptions {
6003 root: dispatch.root,
6004 config_path: &cli.config,
6005 output,
6006 json_style: dispatch.json_style,
6007 no_cache: cli.no_cache,
6008 threads: dispatch.threads,
6009 quiet,
6010 allow_remote_extends: cli.allow_remote_extends,
6011 mode: args.mode,
6012 near: args.near,
6013 min_tokens: args.min_tokens,
6014 min_lines: args.min_lines,
6015 min_occurrences: args.min_occurrences,
6016 threshold: args.threshold,
6017 skip_local: args.skip_local,
6018 cross_language: args.cross_language,
6019 ignore_imports: resolve_ignore_imports(args.ignore_imports, args.no_ignore_imports),
6020 top: args.top,
6021 baseline_path: cli.baseline.as_deref(),
6022 baseline_flag: "--baseline",
6023 save_baseline_path: cli.save_baseline.as_deref(),
6024 fail_on_stale_baseline: cli.fail_on_stale_baseline,
6025 production,
6026 production_override: Some(production),
6027 trace: args.trace.as_deref(),
6028 changed_since: cli.changed_since.as_deref(),
6029 diff_index: None,
6030 use_shared_diff_index: true,
6031 changed_files: None,
6032 workspace: cli.workspace.as_deref(),
6033 changed_workspaces: cli.changed_workspaces.as_deref(),
6034 explain: cli.explain,
6035 explain_skipped: cli.explain_skipped,
6036 summary: cli.summary,
6037 group_by: cli.group_by,
6038 performance: cli.performance,
6039 include_fragments: !args.no_fragments,
6040 scope: args.scope.clone(),
6041 })
6042}
6043
6044struct AuditDispatchArgs {
6045 production_dead_code: bool,
6046 production_health: bool,
6047 production_dupes: bool,
6048 dead_code_baseline: Option<PathBuf>,
6049 health_baseline: Option<PathBuf>,
6050 dupes_baseline: Option<PathBuf>,
6051 max_crap: Option<f64>,
6052 coverage: Option<PathBuf>,
6053 coverage_root: Option<PathBuf>,
6054 no_css: bool,
6055 css_deep: bool,
6056 no_css_deep: bool,
6057 gate: Option<AuditGateArg>,
6058 runtime_coverage: Option<PathBuf>,
6059 min_invocations_hot: u64,
6060 gate_marker: Option<String>,
6061 brief: bool,
6062 max_decisions: usize,
6063 walkthrough_guide: bool,
6065 walkthrough_file: Option<PathBuf>,
6068 walkthrough: bool,
6070 mark_viewed: Vec<PathBuf>,
6072 show_cleared: bool,
6074 show_deprioritized: bool,
6076 scope: Option<PathBuf>,
6077}
6078
6079struct ResolvedAuditInputs {
6080 audit_cfg: fallow_config::AuditConfig,
6081 cache_dir: PathBuf,
6082 production: ProductionModes,
6083 dead_code_baseline: Option<PathBuf>,
6084 health_baseline: Option<PathBuf>,
6085 dupes_baseline: Option<PathBuf>,
6086 coverage: Option<PathBuf>,
6090 coverage_root: Option<PathBuf>,
6091}
6092
6093fn dispatch_audit(dispatch: &DispatchContext<'_>, args: &AuditDispatchArgs) -> ExitCode {
6094 let cli = dispatch.cli;
6095 let output = dispatch.output;
6096
6097 if cli.baseline.is_some() || cli.save_baseline.is_some() {
6098 return emit_error(
6099 "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>`)",
6100 2,
6101 output,
6102 );
6103 }
6104
6105 let inputs = match resolve_audit_inputs(dispatch, args) {
6106 Ok(inputs) => inputs,
6107 Err(code) => return code,
6108 };
6109
6110 run_resolved_audit(dispatch, args, &inputs)
6111}
6112
6113fn resolve_audit_inputs(
6114 dispatch: &DispatchContext<'_>,
6115 args: &AuditDispatchArgs,
6116) -> Result<ResolvedAuditInputs, ExitCode> {
6117 let cli = dispatch.cli;
6118 let root = dispatch.root;
6119 let output = dispatch.output;
6120 let config = load_config(
6121 root,
6122 &cli.config,
6123 LoadConfigArgs {
6124 output,
6125 no_cache: cli.no_cache,
6126 threads: dispatch.threads,
6127 production: cli.production,
6128 quiet: dispatch.quiet,
6129 allow_remote_extends: cli.allow_remote_extends,
6130 },
6131 )?;
6132 let cache_dir = config.cache_dir.clone();
6133 let audit_cfg = config.audit;
6134 let production = resolve_production_modes(
6135 cli,
6136 root,
6137 output,
6138 args.production_dead_code,
6139 args.production_health,
6140 args.production_dupes,
6141 )?;
6142 let resolved_dead_code_baseline = resolve_audit_baseline_path(
6143 root,
6144 args.dead_code_baseline.as_deref(),
6145 audit_cfg.dead_code_baseline.as_deref(),
6146 );
6147 let resolved_health_baseline = resolve_audit_baseline_path(
6148 root,
6149 args.health_baseline.as_deref(),
6150 audit_cfg.health_baseline.as_deref(),
6151 );
6152 let resolved_dupes_baseline = resolve_audit_baseline_path(
6153 root,
6154 args.dupes_baseline.as_deref(),
6155 audit_cfg.dupes_baseline.as_deref(),
6156 );
6157 let coverage_inputs = resolve_coverage_inputs(
6158 args.coverage.as_deref(),
6159 args.coverage_root.as_deref(),
6160 output,
6161 || Ok(config.health),
6162 )?;
6163
6164 Ok(ResolvedAuditInputs {
6165 audit_cfg,
6166 cache_dir,
6167 production,
6168 dead_code_baseline: resolved_dead_code_baseline,
6169 health_baseline: resolved_health_baseline,
6170 dupes_baseline: resolved_dupes_baseline,
6171 coverage: coverage_inputs.coverage,
6172 coverage_root: coverage_inputs.coverage_root,
6173 })
6174}
6175
6176fn audit_css_enabled(config: &fallow_config::AuditConfig, args: &AuditDispatchArgs) -> bool {
6177 !args.no_css && config.css.unwrap_or(true)
6178}
6179
6180fn audit_css_deep_enabled(config: &fallow_config::AuditConfig, args: &AuditDispatchArgs) -> bool {
6181 audit_css_enabled(config, args)
6182 && !args.no_css_deep
6183 && (args.css_deep || config.css_deep.unwrap_or(true))
6184}
6185
6186fn run_resolved_audit(
6187 dispatch: &DispatchContext<'_>,
6188 args: &AuditDispatchArgs,
6189 inputs: &ResolvedAuditInputs,
6190) -> ExitCode {
6191 let cli = dispatch.cli;
6192 audit::run_audit_with_type_aware(
6193 &audit::AuditOptions {
6194 root: dispatch.root,
6195 config_path: &cli.config,
6196 cache_dir: &inputs.cache_dir,
6197 output: dispatch.output,
6198 json_style: dispatch.json_style,
6199 no_cache: cli.no_cache,
6200 threads: dispatch.threads,
6201 quiet: dispatch.quiet,
6202 allow_remote_extends: cli.allow_remote_extends,
6203 changed_since: cli.changed_since.as_deref(),
6204 production: cli.production,
6205 production_dead_code: Some(inputs.production.dead_code),
6206 production_health: Some(inputs.production.health),
6207 production_dupes: Some(inputs.production.dupes),
6208 workspace: cli.workspace.as_deref(),
6209 changed_workspaces: cli.changed_workspaces.as_deref(),
6210 explain: cli.explain,
6211 explain_skipped: cli.explain_skipped,
6212 performance: cli.performance,
6213 group_by: cli.group_by,
6214 dead_code_baseline: inputs.dead_code_baseline.as_deref(),
6215 health_baseline: inputs.health_baseline.as_deref(),
6216 dupes_baseline: inputs.dupes_baseline.as_deref(),
6217 health_baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
6218 fail_on_stale_baseline: cli.fail_on_stale_baseline,
6219 max_crap: args.max_crap,
6220 coverage: inputs.coverage.as_deref(),
6221 coverage_root: inputs.coverage_root.as_deref(),
6222 gate: args.gate.map_or(inputs.audit_cfg.gate, Into::into),
6223 include_entry_exports: cli.include_entry_exports,
6224 fail_on_parse_error: cli.fail_on_parse_error,
6225 css: audit_css_enabled(&inputs.audit_cfg, args),
6229 css_deep: audit_css_deep_enabled(&inputs.audit_cfg, args),
6230 runtime_coverage: args.runtime_coverage.as_deref(),
6231 min_invocations_hot: args.min_invocations_hot,
6232 brief: args.brief,
6233 max_decisions: args.max_decisions,
6234 walkthrough_guide: args.walkthrough_guide,
6235 walkthrough: args.walkthrough,
6236 mark_viewed: &args.mark_viewed,
6237 show_cleared: args.show_cleared,
6238 walkthrough_file: args.walkthrough_file.as_deref(),
6239 show_deprioritized: args.show_deprioritized,
6240 scope: args.scope.clone(),
6241 },
6242 args.gate_marker.as_deref(),
6243 audit::AuditTypeAwareOptions {
6244 enabled: cli.type_aware_override(),
6245 config_default: inputs.audit_cfg.type_aware,
6246 projects: &cli.type_aware_project,
6247 require: cli.type_aware_require.map(Into::into),
6248 },
6249 )
6250}
6251
6252fn dispatch_decision_surface(dispatch: &DispatchContext<'_>, max_decisions: usize) -> ExitCode {
6256 let args = decision_surface_audit_args(max_decisions);
6257 let inputs = match resolve_audit_inputs(dispatch, &args) {
6258 Ok(inputs) => inputs,
6259 Err(code) => return code,
6260 };
6261 if dispatch.cli.fail_on_stale_baseline {
6265 for path in [
6266 &inputs.dead_code_baseline,
6267 &inputs.health_baseline,
6268 &inputs.dupes_baseline,
6269 ] {
6270 baseline_gate::note_stood_down(
6271 path.as_deref(),
6272 true,
6273 "decision-surface renders a brief without exit gates",
6274 );
6275 }
6276 }
6277 audit::run_decision_surface(&decision_surface_audit_options(
6278 dispatch,
6279 &inputs,
6280 max_decisions,
6281 ))
6282}
6283
6284fn decision_surface_audit_args(max_decisions: usize) -> AuditDispatchArgs {
6285 AuditDispatchArgs {
6286 production_dead_code: false,
6287 production_health: false,
6288 production_dupes: false,
6289 dead_code_baseline: None,
6290 health_baseline: None,
6291 dupes_baseline: None,
6292 max_crap: None,
6293 coverage: None,
6294 coverage_root: None,
6295 no_css: true,
6296 css_deep: false,
6297 no_css_deep: false,
6298 gate: None,
6299 runtime_coverage: None,
6300 min_invocations_hot: 0,
6301 gate_marker: None,
6302 brief: true,
6303 max_decisions,
6304 walkthrough_guide: false,
6305 walkthrough_file: None,
6306 walkthrough: false,
6307 mark_viewed: Vec::new(),
6308 show_cleared: false,
6309 show_deprioritized: false,
6310 scope: None,
6311 }
6312}
6313
6314fn decision_surface_audit_options<'a>(
6315 dispatch: &'a DispatchContext<'a>,
6316 inputs: &'a ResolvedAuditInputs,
6317 max_decisions: usize,
6318) -> audit::AuditOptions<'a> {
6319 let cli = dispatch.cli;
6320 audit::AuditOptions {
6321 root: dispatch.root,
6322 config_path: &cli.config,
6323 cache_dir: &inputs.cache_dir,
6324 output: dispatch.output,
6325 json_style: dispatch.json_style,
6326 no_cache: cli.no_cache,
6327 threads: dispatch.threads,
6328 quiet: dispatch.quiet,
6329 allow_remote_extends: cli.allow_remote_extends,
6330 changed_since: cli.changed_since.as_deref(),
6331 production: cli.production,
6332 production_dead_code: Some(inputs.production.dead_code),
6333 production_health: Some(inputs.production.health),
6334 production_dupes: Some(inputs.production.dupes),
6335 workspace: cli.workspace.as_deref(),
6336 changed_workspaces: cli.changed_workspaces.as_deref(),
6337 explain: cli.explain,
6338 explain_skipped: cli.explain_skipped,
6339 performance: cli.performance,
6340 group_by: cli.group_by,
6341 dead_code_baseline: inputs.dead_code_baseline.as_deref(),
6342 health_baseline: inputs.health_baseline.as_deref(),
6343 dupes_baseline: inputs.dupes_baseline.as_deref(),
6344 health_baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
6345 fail_on_stale_baseline: false,
6349 max_crap: None,
6350 coverage: None,
6351 coverage_root: None,
6352 gate: inputs.audit_cfg.gate,
6353 include_entry_exports: cli.include_entry_exports,
6354 fail_on_parse_error: false,
6355 css: false,
6357 css_deep: false,
6358 runtime_coverage: None,
6359 min_invocations_hot: 0,
6360 brief: true,
6361 max_decisions,
6362 walkthrough_guide: false,
6363 walkthrough: false,
6364 mark_viewed: &[],
6365 show_cleared: false,
6366 walkthrough_file: None,
6367 show_deprioritized: false,
6368 scope: None,
6369 }
6370}
6371
6372struct HealthDispatchArgs<'a> {
6373 max_cyclomatic: Option<u16>,
6374 max_cognitive: Option<u16>,
6375 max_crap: Option<f64>,
6376 top: Option<usize>,
6377 sort: health::SortBy,
6378 complexity: bool,
6379 complexity_breakdown: bool,
6380 file_scores: bool,
6381 coverage_gaps: bool,
6382 hotspots: bool,
6383 ownership: bool,
6384 ownership_emails: Option<fallow_config::EmailMode>,
6385 targets: bool,
6386 type_coupling: bool,
6387 css: bool,
6388 effort: Option<EffortFilter>,
6389 score: bool,
6390 min_score: Option<f64>,
6391 min_severity: Option<fallow_output::FindingSeverity>,
6392 report_only: bool,
6393 fail_on_stale_baseline: bool,
6394 fail_on_parse_error: bool,
6395 since: Option<&'a str>,
6396 min_commits: Option<u32>,
6397 save_snapshot: Option<&'a Option<String>>,
6398 trend: bool,
6399 coverage: Option<&'a std::path::Path>,
6400 coverage_root: Option<&'a std::path::Path>,
6401 runtime_coverage: Option<&'a std::path::Path>,
6402 min_invocations_hot: u64,
6403 min_observation_volume: Option<u32>,
6404 low_traffic_threshold: Option<f64>,
6405 scope: Option<std::path::PathBuf>,
6406}
6407
6408type ResolvedHealthCoverageInputs = fallow_api::CoverageInputs;
6409
6410fn resolve_coverage_inputs(
6421 cli_coverage: Option<&std::path::Path>,
6422 cli_coverage_root: Option<&std::path::Path>,
6423 output: fallow_config::OutputFormat,
6424 config_health: impl FnOnce() -> Result<fallow_config::HealthConfig, ExitCode>,
6425) -> Result<ResolvedHealthCoverageInputs, ExitCode> {
6426 let explicit = fallow_api::CoverageInputs {
6427 coverage: cli_coverage.map(std::path::Path::to_path_buf),
6428 coverage_root: cli_coverage_root.map(std::path::Path::to_path_buf),
6429 };
6430 let env = fallow_api::CoverageInputs {
6431 coverage: path_from_env("FALLOW_COVERAGE"),
6432 coverage_root: path_from_env("FALLOW_COVERAGE_ROOT"),
6433 };
6434 let config_health = if fallow_api::CoverageInputs::needs_config_layer(&explicit, &env) {
6435 Some(config_health()?)
6436 } else {
6437 None
6438 };
6439
6440 fallow_api::resolve_coverage_inputs(explicit, env, config_health.as_ref())
6441 .map_err(|err| emit_error(&err.to_string(), 2, output))
6442}
6443
6444fn resolve_health_coverage_inputs(
6447 dispatch: &DispatchContext<'_>,
6448 cli_coverage: Option<&std::path::Path>,
6449 cli_coverage_root: Option<&std::path::Path>,
6450) -> Result<ResolvedHealthCoverageInputs, ExitCode> {
6451 resolve_coverage_inputs(cli_coverage, cli_coverage_root, dispatch.output, || {
6452 Ok(load_config(
6453 dispatch.root,
6454 &dispatch.cli.config,
6455 LoadConfigArgs {
6456 output: dispatch.output,
6457 no_cache: dispatch.cli.no_cache,
6458 threads: dispatch.threads,
6459 production: dispatch.cli.production,
6460 quiet: dispatch.quiet,
6461 allow_remote_extends: dispatch.cli.allow_remote_extends,
6462 },
6463 )?
6464 .health)
6465 })
6466}
6467
6468fn path_from_env(name: &str) -> Option<PathBuf> {
6469 std::env::var_os(name)
6470 .filter(|value| !value.is_empty())
6471 .map(PathBuf::from)
6472}
6473
6474fn validate_health_report_only_gate(
6475 report_only: bool,
6476 min_score: Option<f64>,
6477 min_severity: Option<fallow_output::FindingSeverity>,
6478 output: fallow_config::OutputFormat,
6479) -> Result<(), ExitCode> {
6480 if report_only && (min_score.is_some() || min_severity.is_some()) {
6481 return Err(emit_error(
6482 "--report-only cannot be combined with --min-score or --min-severity. \
6483 --report-only always exits 0; drop it to gate on score/severity, or \
6484 drop the gate flags to stay advisory.",
6485 2,
6486 output,
6487 ));
6488 }
6489
6490 Ok(())
6491}
6492
6493fn resolve_runtime_coverage_options(
6494 runtime_coverage: Option<&std::path::Path>,
6495 min_invocations_hot: u64,
6496 min_observation_volume: Option<u32>,
6497 low_traffic_threshold: Option<f64>,
6498 output: fallow_config::OutputFormat,
6499) -> Result<Option<fallow_engine::health::RuntimeCoverageOptions>, ExitCode> {
6500 let Some(path) = runtime_coverage else {
6501 return Ok(None);
6502 };
6503
6504 health::coverage::prepare_options(
6505 path,
6506 min_invocations_hot,
6507 min_observation_volume,
6508 low_traffic_threshold,
6509 output,
6510 )
6511 .map(Some)
6512}
6513
6514fn dispatch_health(dispatch: &DispatchContext<'_>, args: &HealthDispatchArgs<'_>) -> ExitCode {
6515 let cli = dispatch.cli;
6516 let root = dispatch.root;
6517 let (output, _quiet, _fail_on_issues) =
6518 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
6519 if let Err(code) = validate_health_report_only_gate(
6520 args.report_only,
6521 args.min_score,
6522 args.min_severity,
6523 output,
6524 ) {
6525 return code;
6526 }
6527 let runtime_coverage = match resolve_runtime_coverage_options(
6528 args.runtime_coverage,
6529 args.min_invocations_hot,
6530 args.min_observation_volume,
6531 args.low_traffic_threshold,
6532 output,
6533 ) {
6534 Ok(options) => options,
6535 Err(code) => return code,
6536 };
6537 let production = match resolve_production_modes(cli, root, output, false, false, false) {
6538 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::Health),
6539 Err(code) => return code,
6540 };
6541 let coverage_inputs =
6542 match resolve_health_coverage_inputs(dispatch, args.coverage, args.coverage_root) {
6543 Ok(inputs) => inputs,
6544 Err(code) => return code,
6545 };
6546 let run = derive_health_dispatch_run(args, output, &coverage_inputs, runtime_coverage);
6547 run_health_dispatch(dispatch, args, ResolvedHealthDispatch { run, production })
6548}
6549
6550fn derive_health_dispatch_run<'a>(
6551 args: &'a HealthDispatchArgs<'a>,
6552 output: fallow_config::OutputFormat,
6553 coverage_inputs: &'a ResolvedHealthCoverageInputs,
6554 runtime_coverage: Option<fallow_engine::health::RuntimeCoverageOptions>,
6555) -> fallow_engine::health::HealthRunOptions<'a> {
6556 let mut run = fallow_engine::health::derive_health_run_options(
6557 fallow_engine::health::HealthRunOptionsInput {
6558 output,
6559 thresholds: health_threshold_overrides(args),
6560 top: args.top,
6561 sort: args.sort.clone().into(),
6562 complexity: args.complexity,
6563 file_scores: args.file_scores,
6564 coverage_gaps: args.coverage_gaps,
6565 hotspots: args.hotspots,
6566 ownership: args.ownership,
6567 ownership_emails: args.ownership_emails,
6568 targets: args.targets,
6569 css: args.css,
6570 effort: args.effort.map(EffortFilter::to_estimate),
6571 score: args.score,
6572 gates: health_gate_options(args),
6573 snapshot_requested: args.save_snapshot.is_some(),
6574 trend: args.trend,
6575 since: args.since,
6576 min_commits: args.min_commits,
6577 coverage_inputs: health_coverage_inputs(coverage_inputs),
6578 runtime_coverage,
6579 },
6580 );
6581 if args.type_coupling && !run.sections.any_section {
6582 run.sections = fallow_engine::health::DerivedHealthSections {
6583 any_section: true,
6584 complexity: false,
6585 file_scores: false,
6586 coverage_gaps: false,
6587 hotspots: false,
6588 targets: false,
6589 css: false,
6590 score: false,
6591 force_full: false,
6592 score_only_output: false,
6593 };
6594 }
6595 run
6596}
6597
6598fn health_threshold_overrides(
6599 args: &HealthDispatchArgs<'_>,
6600) -> fallow_engine::health::HealthThresholdOverrides {
6601 fallow_engine::health::HealthThresholdOverrides {
6602 max_cyclomatic: args.max_cyclomatic,
6603 max_cognitive: args.max_cognitive,
6604 max_crap: args.max_crap,
6605 }
6606}
6607
6608fn health_gate_options(args: &HealthDispatchArgs<'_>) -> fallow_engine::health::HealthGateOptions {
6609 fallow_engine::health::HealthGateOptions {
6610 min_score: args.min_score,
6611 min_severity: args.min_severity,
6612 report_only: args.report_only,
6613 fail_on_stale_baseline: args.fail_on_stale_baseline,
6614 fail_on_parse_error: args.fail_on_parse_error,
6615 fail_on_issues: false,
6616 }
6617}
6618
6619fn health_coverage_inputs(
6620 coverage_inputs: &ResolvedHealthCoverageInputs,
6621) -> fallow_engine::health::HealthCoverageInputs<'_> {
6622 fallow_engine::health::HealthCoverageInputs {
6623 coverage: coverage_inputs.coverage.as_deref(),
6624 coverage_root: coverage_inputs.coverage_root.as_deref(),
6625 coverage_relocated: false,
6626 }
6627}
6628
6629struct ResolvedHealthDispatch<'a> {
6633 run: fallow_engine::health::HealthRunOptions<'a>,
6634 production: bool,
6635}
6636
6637fn run_health_dispatch(
6640 dispatch: &DispatchContext<'_>,
6641 args: &HealthDispatchArgs<'_>,
6642 resolved: ResolvedHealthDispatch<'_>,
6643) -> ExitCode {
6644 let cli = dispatch.cli;
6645 let (output, quiet, fail_on_issues) =
6646 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
6647 let mut run = resolved.run;
6648 run.gates.fail_on_issues = fail_on_issues;
6649 let sections = run.sections;
6650 let production = resolved.production;
6651 health::run_health(
6652 &HealthOptions {
6653 root: dispatch.root,
6654 config_path: &cli.config,
6655 output,
6656 no_cache: cli.no_cache,
6657 threads: dispatch.threads,
6658 quiet,
6659 thresholds: run.thresholds,
6660 top: run.top,
6661 sort: run.sort,
6662 production,
6663 production_override: Some(production),
6664 allow_remote_extends: cli.allow_remote_extends,
6665 changed_since: cli.changed_since.as_deref(),
6666 diff_index: None,
6667 use_shared_diff_index: true,
6668 workspace: cli.workspace.as_deref(),
6669 changed_workspaces: cli.changed_workspaces.as_deref(),
6670 baseline: cli.baseline.as_deref(),
6671 save_baseline: cli.save_baseline.as_deref(),
6672 baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
6673 baseline_mode_explicit: cli.baseline_mode.is_some(),
6674 complexity: sections.complexity,
6675 file_scores: sections.file_scores,
6676 coverage_gaps: sections.coverage_gaps,
6677 config_activates_coverage_gaps: !sections.any_section,
6678 hotspots: sections.hotspots,
6679 ownership: run.ownership,
6680 ownership_emails: run.ownership_emails,
6681 targets: sections.targets,
6682 css: sections.css,
6683 css_deep: false,
6684 force_full: sections.force_full,
6685 score_only_output: sections.score_only_output,
6686 enforce_coverage_gap_gate: true,
6687 effort: run.effort,
6688 score: sections.score,
6689 gates: run.gates,
6690 since: run.since,
6691 min_commits: run.min_commits,
6692 explain: cli.explain,
6693 summary: cli.summary,
6694 save_snapshot: args
6695 .save_snapshot
6696 .map(|opt| PathBuf::from(opt.as_deref().unwrap_or_default())),
6697 trend: args.trend,
6698 coverage_inputs: run.coverage_inputs,
6699 performance: cli.performance,
6700 runtime_coverage: run.runtime_coverage,
6701 churn_file: cli.churn_file.as_deref(),
6702 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
6703 complexity_breakdown: args.complexity_breakdown,
6704 group_by: cli.group_by.map(Into::into),
6705 scope: args.scope.clone(),
6706 },
6707 dispatch.json_style,
6708 &health::TypeAwareHealthOptions {
6709 enabled: cli.type_aware_override(),
6710 requested: args.type_coupling,
6711 unfiltered: health_type_coupling_is_default_section(args),
6712 projects: &cli.type_aware_project,
6713 require: cli.type_aware_require.map(Into::into),
6714 },
6715 )
6716}
6717
6718fn health_type_coupling_is_default_section(args: &HealthDispatchArgs<'_>) -> bool {
6719 !args.complexity
6720 && !args.file_scores
6721 && !args.coverage_gaps
6722 && !args.hotspots
6723 && !args.ownership
6724 && !args.targets
6725 && !args.css
6726 && !args.score
6727 && args.min_score.is_none()
6728 && args.min_severity.is_none()
6729 && args.runtime_coverage.is_none()
6730}
6731
6732#[cfg(test)]
6733mod tests {
6734 use super::*;
6735
6736 #[test]
6740 fn cli_definition_has_no_flag_collisions() {
6741 use clap::CommandFactory;
6742 Cli::command().debug_assert();
6743 }
6744
6745 #[test]
6746 fn impact_statusline_subcommand_parses() {
6747 use clap::Parser;
6748
6749 let cli = Cli::try_parse_from(["fallow", "impact", "statusline"]).expect("argv parses");
6750 assert!(matches!(
6751 cli.command,
6752 Some(Command::Impact {
6753 subcommand: Some(ImpactCli::Statusline),
6754 ..
6755 })
6756 ));
6757 }
6758
6759 #[test]
6760 fn impact_statusline_bypasses_command_epilogue() {
6761 use clap::Parser;
6762
6763 let statusline =
6764 Cli::try_parse_from(["fallow", "impact", "statusline"]).expect("argv parses");
6765 assert!(is_impact_statusline(&statusline));
6766
6767 let status = Cli::try_parse_from(["fallow", "impact", "status"]).expect("argv parses");
6768 assert!(!is_impact_statusline(&status));
6769
6770 let all_statusline =
6771 Cli::try_parse_from(["fallow", "impact", "--all", "statusline"]).expect("argv parses");
6772 assert!(!is_impact_statusline(&all_statusline));
6773 }
6774
6775 #[test]
6776 fn regression_baseline_help_explains_the_default_destination() {
6777 use clap::CommandFactory;
6778 let help = Cli::command().render_long_help().to_string();
6779
6780 assert!(help.contains("Omit PATH to update regression.baseline"));
6781 assert!(help.contains("discovered fallow config"));
6782 assert!(help.contains("create .fallowrc.json when none exists"));
6783 }
6784
6785 #[test]
6789 fn after_help_lists_every_task_matrix_command() {
6790 for row in crate::task_matrix::TASK_MATRIX {
6791 assert!(
6792 TOP_LEVEL_AFTER_LONG_HELP.contains(row.command),
6793 "root --help cheat sheet is missing task-matrix command '{}'; \
6794 update the top_level_task_cheat_sheet! fragment to match TASK_MATRIX",
6795 row.command
6796 );
6797 }
6798 }
6799
6800 #[test]
6807 fn after_help_lists_every_visible_subcommand() {
6808 use clap::CommandFactory;
6809
6810 for sub in Cli::command().get_subcommands() {
6811 if sub.is_hide_set() {
6812 continue;
6813 }
6814 let name = sub.get_name();
6815 let listed = TOP_LEVEL_AFTER_LONG_HELP
6816 .lines()
6817 .any(|line| line.split_whitespace().next() == Some(name));
6818 assert!(
6819 listed,
6820 "root --help command list is missing subcommand '{name}'; \
6821 add it to a top_level_*_command_groups! section"
6822 );
6823 }
6824 }
6825
6826 #[test]
6830 fn short_help_stays_scannable_with_cheat_sheet_and_pointer() {
6831 use clap::CommandFactory;
6832
6833 let help = Cli::command().render_help().to_string();
6834 let lines = help.lines().count();
6835 assert!(
6836 lines < 90,
6837 "root -h grew to {lines} lines; keep the short surface under 90 \
6838 (curate hide_short_help and the short after-help instead)"
6839 );
6840 assert!(help.contains("When the agent is about to..."));
6841 assert!(help.contains("Run fallow --help for the complete command list."));
6842 }
6843
6844 #[test]
6848 fn high_value_commands_route_to_distinct_workflows() {
6849 use clap::Parser;
6850 use fallow_config::OutputFormat;
6851
6852 let distinct = [
6853 (vec!["fallow", "impact"], telemetry::Workflow::Impact),
6854 (vec!["fallow", "security"], telemetry::Workflow::Security),
6855 (vec!["fallow", "fix"], telemetry::Workflow::Fix),
6856 (
6857 vec!["fallow", "explain", "unused-exports"],
6858 telemetry::Workflow::Explain,
6859 ),
6860 (
6861 vec!["fallow", "watch"],
6862 telemetry::Workflow::CodeQualityReview,
6863 ),
6864 (
6865 vec!["fallow", "list"],
6866 telemetry::Workflow::ProjectInventory,
6867 ),
6868 (
6869 vec!["fallow", "workspaces"],
6870 telemetry::Workflow::ProjectInventory,
6871 ),
6872 (
6873 vec!["fallow", "schema"],
6874 telemetry::Workflow::ProjectInventory,
6875 ),
6876 (vec!["fallow", "init"], telemetry::Workflow::Setup),
6877 (
6878 vec!["fallow", "hooks", "install", "--target", "git"],
6879 telemetry::Workflow::Setup,
6880 ),
6881 (vec!["fallow", "config-schema"], telemetry::Workflow::Setup),
6882 (vec!["fallow", "plugin-schema"], telemetry::Workflow::Setup),
6883 (
6884 vec!["fallow", "rule-pack-schema"],
6885 telemetry::Workflow::Setup,
6886 ),
6887 (vec!["fallow", "config"], telemetry::Workflow::Setup),
6888 (
6889 vec!["fallow", "ci-template", "gitlab"],
6890 telemetry::Workflow::Setup,
6891 ),
6892 (vec!["fallow", "migrate"], telemetry::Workflow::Setup),
6893 (
6894 vec!["fallow", "telemetry", "status"],
6895 telemetry::Workflow::Setup,
6896 ),
6897 (vec!["fallow", "setup-hooks"], telemetry::Workflow::Setup),
6898 (
6899 vec!["fallow", "audit-cache", "remove", "--root", "."],
6900 telemetry::Workflow::Setup,
6901 ),
6902 (
6903 vec!["fallow", "license", "status"],
6904 telemetry::Workflow::License,
6905 ),
6906 ];
6907 for (argv, expected) in distinct {
6908 let cli = Cli::try_parse_from(&argv).expect("argv parses");
6909 assert_eq!(
6910 telemetry_workflow_for_command(cli.command.as_ref(), OutputFormat::Json),
6911 expected,
6912 "{argv:?} should map to {expected:?}"
6913 );
6914 }
6915 }
6916
6917 #[test]
6922 fn version_flag_accepts_lower_v_upper_v_and_long() {
6923 use clap::CommandFactory;
6924 for argv in [["fallow", "-v"], ["fallow", "-V"], ["fallow", "--version"]] {
6925 let err = Cli::command()
6926 .try_get_matches_from(argv)
6927 .expect_err("version flag should short-circuit parsing");
6928 assert_eq!(
6929 err.kind(),
6930 clap::error::ErrorKind::DisplayVersion,
6931 "{argv:?} should trigger the Version action"
6932 );
6933 }
6934 }
6935
6936 #[test]
6941 fn cli_help_text_contains_no_implementation_status_wording() {
6942 use clap::CommandFactory;
6943 let mut root = Cli::command();
6944 let mut violations: Vec<(String, String)> = Vec::new();
6945 visit_help(&mut root, "fallow", &mut violations);
6946 assert!(
6947 violations.is_empty(),
6948 "found implementation-status wording in --help output:\n{}",
6949 violations
6950 .iter()
6951 .map(|(cmd, line)| format!(" {cmd}: {line}"))
6952 .collect::<Vec<_>>()
6953 .join("\n")
6954 );
6955 }
6956
6957 #[test]
6958 fn dependency_override_help_is_package_manager_neutral() {
6959 use clap::CommandFactory;
6960 let help = Cli::command()
6961 .find_subcommand_mut("dead-code")
6962 .expect("dead-code command")
6963 .render_long_help()
6964 .to_string();
6965
6966 assert!(help.contains("Only report unused package-manager dependency overrides"));
6967 assert!(help.contains("Only report misconfigured package-manager dependency overrides"));
6968 assert!(!help.contains("unused pnpm dependency overrides"));
6969 assert!(!help.contains("misconfigured pnpm dependency overrides"));
6970 }
6971
6972 #[test]
6973 fn top_level_help_groups_commands_by_workflow() {
6974 use clap::CommandFactory;
6975 let help = Cli::command().render_long_help().to_string();
6976 let expected_order = [
6977 "Analysis:",
6978 " dead-code",
6979 " dupes",
6980 " health",
6981 " flags",
6982 " security",
6983 " audit",
6984 "Workflow:",
6985 " watch",
6986 " fix",
6987 "Project inspection:",
6988 " list",
6989 " workspaces",
6990 " explain",
6991 " impact",
6992 " viz",
6993 "Setup and configuration:",
6994 " init",
6995 " recommend",
6996 " migrate",
6997 " config",
6998 " config-schema",
6999 " plugin-schema",
7000 " plugin-check",
7001 " rule-pack-schema",
7002 "Automation and CI:",
7003 " ci",
7004 " ci-template",
7005 " hooks",
7006 " setup-hooks",
7007 "Runtime coverage:",
7008 " coverage",
7009 " license",
7010 "Reference:",
7011 " schema",
7012 " help",
7013 "Options:",
7014 ];
7015 let mut cursor = 0;
7016 for needle in expected_order {
7017 let Some(offset) = help[cursor..].find(needle) else {
7018 panic!("top-level help missing `{needle}` after byte {cursor}:\n{help}");
7019 };
7020 cursor += offset + needle.len();
7021 }
7022 }
7023
7024 #[test]
7025 fn security_help_hides_globals_rejected_by_security_validator() {
7026 let help = render_security_help(SecurityHelpTarget::Parent);
7027
7028 for long in SECURITY_UNSUPPORTED_GLOBAL_LONGS {
7029 assert!(
7030 !help_contains_long_flag(&help, long),
7031 "security help must hide unsupported --{long}:\n{help}"
7032 );
7033 }
7034
7035 for long in [
7036 "root",
7037 "config",
7038 "format",
7039 "quiet",
7040 "no-cache",
7041 "threads",
7042 "changed-since",
7043 "diff-file",
7044 "diff-stdin",
7045 "workspace",
7046 "changed-workspaces",
7047 "ci",
7048 "fail-on-issues",
7049 "sarif-file",
7050 "summary",
7051 "output-file",
7052 "max-file-size",
7053 "explain",
7054 "surface",
7055 ] {
7056 assert!(
7057 help_contains_long_flag(&help, long),
7058 "security help must keep supported --{long}:\n{help}"
7059 );
7060 }
7061 }
7062
7063 #[test]
7064 fn security_help_detection_covers_subcommand_and_help_alias_forms() {
7065 assert_eq!(
7066 security_help_target(["security", "--help"]),
7067 Some(SecurityHelpTarget::Parent)
7068 );
7069 assert_eq!(
7070 security_help_target(["security", "-h"]),
7071 Some(SecurityHelpTarget::Parent)
7072 );
7073 assert_eq!(
7074 security_help_target(["--format", "json", "security", "--help"]),
7075 Some(SecurityHelpTarget::Parent)
7076 );
7077 assert_eq!(
7078 security_help_target(["help", "security"]),
7079 Some(SecurityHelpTarget::Parent)
7080 );
7081 assert_eq!(
7082 security_help_target(["security", "survivors", "--help"]),
7083 Some(SecurityHelpTarget::Survivors)
7084 );
7085 assert_eq!(
7086 security_help_target(["security", "survivors", "-h"]),
7087 Some(SecurityHelpTarget::Survivors)
7088 );
7089 assert_eq!(
7090 security_help_target(["help", "security", "survivors"]),
7091 Some(SecurityHelpTarget::Survivors)
7092 );
7093 assert_eq!(
7094 security_help_target(["security", "blind-spots", "--help"]),
7095 Some(SecurityHelpTarget::BlindSpots)
7096 );
7097 assert_eq!(
7098 security_help_target(["help", "security", "blind-spots"]),
7099 Some(SecurityHelpTarget::BlindSpots)
7100 );
7101 assert_eq!(security_help_target(["health", "--help"]), None);
7102 assert_eq!(security_help_target(["help", "health"]), None);
7103 }
7104
7105 #[test]
7106 fn security_unsupported_global_validator_matches_hidden_help_contract() {
7107 for (argv, expected) in [
7108 (vec!["fallow", "security", "--performance"], "--performance"),
7109 (
7110 vec!["fallow", "security", "--baseline", "base.json"],
7111 "--baseline",
7112 ),
7113 (
7114 vec!["fallow", "security", "--fail-on-stale-baseline"],
7115 "--fail-on-stale-baseline",
7116 ),
7117 (
7118 vec!["fallow", "security", "--dupes-mode", "weak"],
7119 "--dupes-mode",
7120 ),
7121 ] {
7122 let cli = Cli::try_parse_from(argv).expect("security global parses before validation");
7123 assert_eq!(unsupported_security_global(&cli), Some(expected));
7124 }
7125
7126 let explain = Cli::try_parse_from(["fallow", "security", "--explain"])
7127 .expect("security --explain parses");
7128 assert_eq!(unsupported_security_global(&explain), None);
7129 }
7130
7131 #[test]
7132 fn programmatic_common_options_track_analysis_affecting_cli_globals() {
7133 use clap::CommandFactory;
7134
7135 let cli_flags: std::collections::BTreeSet<String> = Cli::command()
7136 .get_arguments()
7137 .filter(|arg| arg.is_global_set())
7138 .filter_map(|arg| arg.get_long().map(str::to_owned))
7139 .filter(|name| {
7140 matches!(
7141 name.as_str(),
7142 "root"
7143 | "config"
7144 | "allow-remote-extends"
7145 | "no-cache"
7146 | "threads"
7147 | "changed-since"
7148 | "diff-file"
7149 | "production"
7150 | "workspace"
7151 | "changed-workspaces"
7152 | "explain"
7153 )
7154 })
7155 .collect();
7156 let programmatic_flags: std::collections::BTreeSet<String> =
7157 fallow_api::COMMON_ANALYSIS_OPTION_FLAGS
7158 .iter()
7159 .map(|flag| (*flag).to_owned())
7160 .collect();
7161
7162 assert_eq!(programmatic_flags, cli_flags);
7163 }
7164
7165 #[test]
7166 fn dead_code_registry_filter_flags_are_exposed_by_clap() {
7167 use clap::CommandFactory;
7168
7169 let cli = Cli::command();
7170 let dead_code = cli
7171 .get_subcommands()
7172 .find(|command| command.get_name() == "dead-code")
7173 .expect("dead-code subcommand is registered");
7174 let cli_flags: std::collections::BTreeSet<String> = dead_code
7175 .get_arguments()
7176 .filter_map(|arg| arg.get_long().map(|long| format!("--{long}")))
7177 .collect();
7178
7179 for flag in fallow_types::issue_meta::DEAD_CODE_FILTER_FLAGS.iter() {
7180 assert!(
7181 cli_flags.contains(*flag),
7182 "registry filter flag {flag} is missing from dead-code clap args"
7183 );
7184 }
7185 }
7186
7187 fn help_contains_long_flag(help: &str, long: &str) -> bool {
7188 let flag = format!("--{long}");
7189 help.split(|c: char| c.is_whitespace() || c == ',' || c == '[' || c == ']')
7190 .any(|token| token == flag)
7191 }
7192
7193 fn visit_help(cmd: &mut clap::Command, path: &str, violations: &mut Vec<(String, String)>) {
7194 let help = cmd.render_long_help().to_string();
7195 for line in scan_forbidden(&help) {
7196 violations.push((path.to_owned(), line));
7197 }
7198 let names: Vec<String> = cmd
7199 .get_subcommands()
7200 .map(|sub| sub.get_name().to_owned())
7201 .collect();
7202 for name in names {
7203 if name == "help" {
7204 continue;
7205 }
7206 if let Some(sub) = cmd.find_subcommand_mut(&name) {
7207 let sub_path = format!("{path} {name}");
7208 visit_help(sub, &sub_path, violations);
7209 }
7210 }
7211 }
7212
7213 fn scan_forbidden(s: &str) -> Vec<String> {
7214 let lower = s.to_ascii_lowercase();
7215 let mut out = Vec::new();
7216 for word in ["stub", "placeholder"] {
7217 if let Some(idx) = find_whole_word(&lower, word) {
7218 out.push(extract_line(s, idx));
7219 }
7220 }
7221 if let Some(idx) = lower.find("not yet") {
7222 out.push(extract_line(s, idx));
7223 }
7224 out
7225 }
7226
7227 fn find_whole_word(haystack: &str, word: &str) -> Option<usize> {
7228 let bytes = haystack.as_bytes();
7229 let mut start = 0;
7230 while let Some(rel) = haystack[start..].find(word) {
7231 let abs = start + rel;
7232 let before_ok = abs == 0 || !bytes[abs - 1].is_ascii_alphanumeric();
7233 let after_idx = abs + word.len();
7234 let after_ok = after_idx >= bytes.len() || !bytes[after_idx].is_ascii_alphanumeric();
7235 if before_ok && after_ok {
7236 return Some(abs);
7237 }
7238 start = abs + word.len();
7239 }
7240 None
7241 }
7242
7243 fn extract_line(s: &str, byte_idx: usize) -> String {
7244 let line_start = s[..byte_idx].rfind('\n').map_or(0, |i| i + 1);
7245 let line_end = s[byte_idx..].find('\n').map_or(s.len(), |i| byte_idx + i);
7246 s[line_start..line_end].trim().to_owned()
7247 }
7248
7249 #[test]
7250 fn emit_error_returns_given_exit_code() {
7251 let code = emit_error("test error", 2, fallow_config::OutputFormat::Human);
7252 assert_eq!(code, ExitCode::from(2));
7253 }
7254
7255 fn telemetry_run_for_mode(mode: telemetry::AnalysisMode) -> TelemetryRun {
7256 TelemetryRun {
7257 workflow: telemetry::Workflow::Health,
7258 output: fallow_config::OutputFormat::Json,
7259 quiet: true,
7260 start: std::time::Instant::now(),
7261 context: telemetry::WorkflowContext {
7262 run_scope: telemetry::RunScope::FullProject,
7263 config_shape: telemetry::ConfigShape::Default,
7264 output_destination: telemetry::OutputDestination::Stdout,
7265 analysis_mode: mode,
7266 },
7267 }
7268 }
7269
7270 #[test]
7271 fn fallback_failure_reason_skips_success_and_findings() {
7272 let run = telemetry_run_for_mode(telemetry::AnalysisMode::Static);
7273
7274 assert_eq!(fallback_failure_reason_for(&run, ExitCode::SUCCESS), None);
7275 assert_eq!(fallback_failure_reason_for(&run, ExitCode::from(1)), None);
7276 }
7277
7278 #[test]
7279 fn fallback_failure_reason_classifies_network_auth_and_analysis() {
7280 let static_run = telemetry_run_for_mode(telemetry::AnalysisMode::Static);
7281 let cloud_run = telemetry_run_for_mode(telemetry::AnalysisMode::ProductionCoverage);
7282
7283 assert_eq!(
7284 fallback_failure_reason_for(&static_run, ExitCode::from(api::NETWORK_EXIT_CODE)),
7285 Some(telemetry::FailureReason::Network),
7286 );
7287 assert_eq!(
7288 fallback_failure_reason_for(&static_run, ExitCode::from(12)),
7289 Some(telemetry::FailureReason::Auth),
7290 );
7291 assert_eq!(
7292 fallback_failure_reason_for(&cloud_run, ExitCode::from(3)),
7293 Some(telemetry::FailureReason::Auth),
7294 );
7295 assert_eq!(
7296 fallback_failure_reason_for(&static_run, ExitCode::from(2)),
7297 Some(telemetry::FailureReason::Analysis),
7298 );
7299 }
7300
7301 #[test]
7302 fn bare_coverage_flags_parse_without_subcommand() {
7303 let cli = Cli::try_parse_from([
7304 "fallow",
7305 "--coverage",
7306 "coverage/coverage-final.json",
7307 "--coverage-root",
7308 "/ci/workspace",
7309 ])
7310 .expect("bare combined coverage flags should parse");
7311 assert!(cli.command.is_none());
7312 assert_eq!(
7313 cli.coverage.as_deref(),
7314 Some(std::path::Path::new("coverage/coverage-final.json"))
7315 );
7316 assert_eq!(
7317 cli.coverage_root.as_deref(),
7318 Some(std::path::Path::new("/ci/workspace"))
7319 );
7320 }
7321
7322 #[test]
7323 fn bare_coverage_before_subcommand_is_detectable() {
7324 let cli = Cli::try_parse_from([
7325 "fallow",
7326 "--coverage",
7327 "coverage/coverage-final.json",
7328 "dead-code",
7329 ])
7330 .expect("clap should parse pre-subcommand bare coverage for custom rejection");
7331 assert!(cli.command.is_some());
7332 assert!(cli_has_bare_coverage_input(&cli));
7333 let message = bare_coverage_subcommand_error_message();
7334 assert!(message.contains("bare combined-mode flags"));
7335 assert!(message.contains("fallow health --coverage <coverage-final.json>"));
7336 }
7337
7338 #[test]
7339 fn bare_combined_baseline_before_subcommand_is_detectable() {
7340 for flag in ["--dupes-baseline", "--health-baseline"] {
7341 let cli = Cli::try_parse_from(["fallow", flag, "x.json", "dead-code"])
7342 .expect("clap should parse a pre-subcommand combined baseline");
7343 assert!(cli.command.is_some());
7344 assert_eq!(cli_bare_combined_baseline_flag(&cli), Some(flag));
7345 let message = bare_combined_baseline_subcommand_error_message(flag);
7346 assert!(message.contains(flag));
7347 assert!(message.contains("omit the subcommand"));
7348 }
7349 let bare = Cli::try_parse_from(["fallow", "--dupes-baseline", "x.json"])
7350 .expect("bare combined baseline should parse");
7351 assert!(bare.command.is_none());
7352 }
7353
7354 #[test]
7355 fn subcommand_coverage_flag_keeps_regular_clap_error() {
7356 let Err(err) = Cli::try_parse_from(["fallow", "dead-code", "--coverage"]) else {
7357 panic!("dead-code --coverage should fail to parse");
7358 };
7359 assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
7360 }
7361
7362 #[test]
7363 fn type_aware_flags_parse_for_semantic_analysis() {
7364 let cli = Cli::try_parse_from([
7365 "fallow",
7366 "dead-code",
7367 "--unused-class-members",
7368 "--type-aware",
7369 "--type-aware-project",
7370 "tsconfig.json",
7371 "--type-aware-project",
7372 "packages/web/tsconfig.json",
7373 ])
7374 .expect("type-aware flag should parse");
7375 assert!(cli.type_aware);
7376 assert_eq!(
7377 cli.type_aware_project,
7378 [
7379 PathBuf::from("tsconfig.json"),
7380 PathBuf::from("packages/web/tsconfig.json")
7381 ]
7382 );
7383 let Some(Command::Check {
7384 unused_class_members,
7385 ..
7386 }) = cli.command
7387 else {
7388 panic!("dead-code should parse as the check command");
7389 };
7390 assert!(unused_class_members);
7391 }
7392
7393 #[test]
7394 fn no_type_aware_conflicts_with_type_aware() {
7395 let Err(err) = Cli::try_parse_from(["fallow", "audit", "--type-aware", "--no-type-aware"])
7396 else {
7397 panic!("--no-type-aware must conflict with --type-aware");
7398 };
7399 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
7400 }
7401
7402 #[test]
7403 fn no_type_aware_forces_semantic_analysis_off() {
7404 let cli = Cli::try_parse_from(["fallow", "audit", "--no-type-aware"])
7405 .expect("--no-type-aware should parse on audit");
7406 assert_eq!(cli.type_aware_override(), Some(false));
7407
7408 let cli = Cli::try_parse_from(["fallow", "dead-code", "--type-aware"])
7409 .expect("--type-aware should parse");
7410 assert_eq!(cli.type_aware_override(), Some(true));
7411
7412 let cli = Cli::try_parse_from(["fallow", "dead-code"]).expect("bare command should parse");
7413 assert_eq!(cli.type_aware_override(), None);
7414 }
7415
7416 #[test]
7417 fn type_aware_status_output_hides_host_paths() {
7418 let root = Path::new("/private/work/project");
7419 let output = type_aware_status_output(
7420 root,
7421 fallow_api::TypeAwareStatus {
7422 available: false,
7423 discovery_source: Some("environment-override"),
7424 companion_path: Some(PathBuf::from("/private/tools/fallow-type-aware")),
7425 package_version: None,
7426 protocol_version: 7,
7427 backend_family: None,
7428 backend_version: None,
7429 remediation: Some(
7430 "failed to launch /private/tools/fallow-type-aware from /private/work/project"
7431 .to_string(),
7432 ),
7433 },
7434 );
7435
7436 assert_eq!(
7437 output.schema_version.0,
7438 fallow_output::TYPE_AWARE_STATUS_SCHEMA_VERSION
7439 );
7440 assert_eq!(output.companion_path.as_deref(), Some("fallow-type-aware"));
7441 let remediation = output.remediation.expect("remediation");
7442 assert!(!remediation.contains("/private/"));
7443 assert!(remediation.contains("fallow-type-aware"));
7444 }
7445
7446 #[test]
7447 fn format_parsing_covers_all_variants() {
7448 assert!(matches!(parse_format_arg("json"), Some(Format::Json)));
7449 assert!(matches!(parse_format_arg("JSON"), Some(Format::Json)));
7450 assert!(matches!(parse_format_arg("human"), Some(Format::Human)));
7451 assert!(matches!(parse_format_arg("sarif"), Some(Format::Sarif)));
7452 assert!(matches!(parse_format_arg("compact"), Some(Format::Compact)));
7453 assert!(matches!(
7454 parse_format_arg("markdown"),
7455 Some(Format::Markdown)
7456 ));
7457 assert!(matches!(parse_format_arg("md"), Some(Format::Markdown)));
7458 assert!(matches!(
7459 parse_format_arg("codeclimate"),
7460 Some(Format::CodeClimate)
7461 ));
7462 assert!(matches!(
7463 parse_format_arg("gitlab-codequality"),
7464 Some(Format::CodeClimate)
7465 ));
7466 assert!(matches!(
7467 parse_format_arg("gitlab-code-quality"),
7468 Some(Format::CodeClimate)
7469 ));
7470 assert!(matches!(
7471 parse_format_arg("pr-comment-github"),
7472 Some(Format::PrCommentGithub)
7473 ));
7474 assert!(matches!(
7475 parse_format_arg("pr-comment-gitlab"),
7476 Some(Format::PrCommentGitlab)
7477 ));
7478 assert!(matches!(
7479 parse_format_arg("review-github"),
7480 Some(Format::ReviewGithub)
7481 ));
7482 assert!(matches!(
7483 parse_format_arg("review-gitlab"),
7484 Some(Format::ReviewGitlab)
7485 ));
7486 assert!(matches!(parse_format_arg("badge"), Some(Format::Badge)));
7487 assert!(parse_format_arg("xml").is_none());
7488 assert!(parse_format_arg("").is_none());
7489 }
7490
7491 #[test]
7492 fn quiet_parsing_logic() {
7493 let parse = |s: &str| -> bool { s == "1" || s.eq_ignore_ascii_case("true") };
7494 assert!(parse("1"));
7495 assert!(parse("true"));
7496 assert!(parse("TRUE"));
7497 assert!(parse("True"));
7498 assert!(!parse("0"));
7499 assert!(!parse("false"));
7500 assert!(!parse("yes"));
7501 }
7502
7503 #[test]
7504 fn tracing_filter_defaults_to_warn_without_env() {
7505 assert_eq!(build_tracing_filter(None).to_string(), "warn");
7506 }
7507
7508 #[test]
7509 fn tracing_filter_respects_explicit_env_directives() {
7510 assert_eq!(build_tracing_filter(Some("info")).to_string(), "info");
7511 }
7512
7513 #[test]
7514 fn tracing_filter_treats_empty_env_as_off() {
7515 assert_eq!(build_tracing_filter(Some("")).to_string(), "off");
7516 assert_eq!(build_tracing_filter(Some(" ")).to_string(), "off");
7517 }
7518}