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