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 cache_notice;
38mod check;
39mod ci;
40mod ci_template;
41mod cli_format;
42mod cli_hooks;
43mod cli_impact;
44mod cli_production;
45mod cli_report;
46mod cli_startup;
47pub use fallow_engine::codeowners;
48mod combined;
49mod config;
50mod coverage;
51mod dupes;
52mod exit_codes;
53pub mod explain;
54mod fix;
55mod flags;
56mod guard;
57mod health;
58mod impact;
59mod init;
60mod inspect;
61mod json_style;
62mod license;
63mod list;
64mod migrate;
65mod onboarding;
66#[cfg(test)]
67mod output_envelope;
68mod output_runtime;
69mod path_util;
70mod plugin_check;
71mod rayon_pool;
72mod regression;
73pub mod report;
74mod rule_pack;
75mod runtime_support;
76mod schema;
77mod security;
78mod security_help;
79mod setup_hooks;
80mod signal;
81mod suppressions;
82mod task_matrix;
83mod telemetry;
84mod trace_chain;
85mod update_check;
86use fallow_engine::validate;
87use fallow_engine::vital_signs;
88mod cli_telemetry;
89mod viz;
90mod watch;
91
92use check::{CheckOptions, IssueFilters, TraceOptions};
93pub(crate) mod error;
95#[cfg(test)]
96use cli_format::parse_format_arg;
97use cli_format::{Format, FormatConfig};
98use cli_hooks::{HooksCli, run_hooks_command};
99use cli_impact::{ImpactCli, ImpactCrossRepoOpts, ImpactSortCli, dispatch_impact};
100use cli_production::{ProductionModes, resolve_production_modes};
101#[cfg(test)]
102use cli_startup::build_tracing_filter;
103use cli_startup::{
104 bare_coverage_subcommand_error_message, cli_has_bare_coverage_input, parse_cli_args,
105 run_pre_dispatch_checks, setup_tracing, validate_inputs,
106};
107#[cfg(test)]
108use cli_telemetry::TelemetryRun;
109#[cfg(test)]
110use cli_telemetry::{fallback_failure_reason_for, telemetry_workflow_for_command};
111use cli_telemetry::{record_run_epilogue, start_telemetry_run};
112use dupes::{DupesMode, DupesOptions};
113use error::emit_error;
114use health::{HealthOptions, SortBy};
115use list::ListOptions;
116pub(crate) use runtime_support::{AnalysisKind, GroupBy};
117pub(crate) use runtime_support::{
118 ConfigLoadOptions, LoadConfigArgs, build_ownership_resolver, load_config,
119 load_config_for_analysis,
120};
121#[cfg(test)]
122use security_help::{SECURITY_UNSUPPORTED_GLOBAL_LONGS, SecurityHelpTarget};
123use security_help::{render_security_help, security_help_target};
124
125const DEFAULT_MIN_INVOCATIONS_HOT: u64 = 100;
126
127const TOP_LEVEL_HELP_TEMPLATE: &str =
128 "{about-with-newline}\n{usage-heading} {usage}{after-help}\n\nOptions:\n{options}";
129
130macro_rules! top_level_task_cheat_sheet {
133 () => {
134 "\
135When the agent is about to...
136 delete an \"unused\" export or file fallow dead-code --trace <file>:<export>
137 prove exact TypeScript symbol consumers fallow dead-code --type-aware --symbol-impact <file>:<export-or-class.method>
138 delete an \"unused\" dependency fallow dead-code --trace-dependency <name>
139 commit or open a PR fallow audit --base <ref>
140 prioritize refactoring fallow health --hotspots --targets
141 ask who owns code fallow health --ownership
142 check untested-but-reachable code fallow health --coverage-gaps
143 consolidate duplication fallow dupes --trace dup:<fingerprint>
144 find feature flags fallow flags
145 check architecture rules before editing fallow guard <files>
146 surface security candidates fallow security
147 inspect a target before editing fallow inspect --file <path>
148 understand a finding fallow explain <issue-type>
149 scope a monorepo --workspace <glob> / --changed-workspaces <ref>"
150 };
151}
152
153macro_rules! top_level_core_command_groups {
154 () => {
155 "\
156Analysis:
157 dead-code Analyze unused code, dependency hygiene, and architecture cycles
158 dupes Find copy-paste and structural code duplication
159 health Analyze complexity, maintainability, hotspots, and coverage gaps
160 flags Detect feature flag usage patterns
161 security Surface local security candidates for agent verification (opt-in)
162 audit Review changed files for dead code, complexity, duplication, and styling
163
164Workflow:
165 watch Re-run analysis as files change
166 fix Auto-fix safe unused-code findings"
167 };
168}
169
170macro_rules! top_level_extended_command_groups {
171 () => {
172 "\
173Project inspection:
174 list List discovered files, entry points, plugins, boundaries, and workspaces
175 inspect Inspect one file or exported symbol as a bundled evidence query
176 trace Trace a symbol's call chain (best-effort, syntactic)
177 guard Show which architecture rules apply to files before editing
178 decision-surface Surface the structural decisions a change embeds (advisory)
179 workspaces Show monorepo workspace discovery diagnostics
180 explain Explain one issue type without running analysis
181 suppressions List active fallow-ignore suppression markers
182 impact Show what fallow has done for you (opt-in, local-only)
183 viz Generate an interactive HTML map of the codebase
184
185Setup and configuration:
186 init Create a fallow config, optionally with a Git hook
187 audit-cache Maintain reusable audit base-snapshot caches
188 recommend Recommend a project-tailored config for an agent to author
189 migrate Migrate knip, jscpd, or stylelint config to fallow
190 config Show the resolved config and loaded config file
191 config-schema Print the fallow config JSON Schema
192 plugin-schema Print the external plugin JSON Schema
193 plugin-check Dry-run external plugins and report what they seed
194 rule-pack Manage declarative rule packs (policy-as-code)
195 rule-pack-schema Print the rule pack JSON Schema
196 type-aware Inspect the optional TypeScript semantic companion
197
198Automation and CI:
199 ci Build PR/MR feedback envelopes
200 ci-template Print or vendor CI integration templates
201 report Re-render saved JSON as GitHub or CodeClimate output
202 hooks Install or remove fallow-managed Git and agent hooks
203 setup-hooks Legacy agent-hook installer
204
205Runtime coverage:
206 coverage Set up or analyze runtime coverage data
207 license Manage the paid-feature license
208 telemetry Manage opt-in product telemetry
209
210Reference:
211 schema Dump the CLI interface as machine-readable JSON
212 help Print this message or the help of a command"
213 };
214}
215
216const TOP_LEVEL_AFTER_HELP: &str = concat!(
217 top_level_task_cheat_sheet!(),
218 "\n\n",
219 top_level_core_command_groups!(),
220 "\n\nRun fallow --help for the complete command list."
221);
222
223const TOP_LEVEL_AFTER_LONG_HELP: &str = concat!(
224 top_level_task_cheat_sheet!(),
225 "\n\n",
226 top_level_core_command_groups!(),
227 "\n\n",
228 top_level_extended_command_groups!(),
229 "\n\n",
230 "When no command is given, fallow runs dead-code + dupes + health together.\n",
231 "Use --only/--skip to select specific analyses."
232);
233
234#[derive(Parser)]
235#[command(
236 name = "fallow",
237 about = "Codebase analyzer for TypeScript/JavaScript: unused code, circular dependencies, code duplication, complexity hotspots, and architecture boundary violations",
238 version,
239 disable_version_flag = true,
240 help_template = TOP_LEVEL_HELP_TEMPLATE,
241 after_help = TOP_LEVEL_AFTER_HELP,
242 after_long_help = TOP_LEVEL_AFTER_LONG_HELP
243)]
244struct Cli {
245 #[command(subcommand)]
246 command: Option<Command>,
247
248 #[arg(
252 short = 'v',
253 visible_short_alias = 'V',
254 long = "version",
255 action = clap::ArgAction::Version
256 )]
257 version: Option<bool>,
258
259 #[arg(short, long, global = true)]
261 root: Option<PathBuf>,
262
263 #[arg(short, long, global = true)]
265 config: Option<PathBuf>,
266
267 #[arg(hide_short_help = true, long, global = true)]
269 allow_remote_extends: bool,
270
271 #[arg(
273 short,
274 long,
275 visible_alias = "output",
276 global = true,
277 default_value = "human"
278 )]
279 format: Format,
280
281 #[arg(hide_short_help = true, long, global = true)]
283 pretty: bool,
284
285 #[arg(short, long, global = true)]
287 quiet: bool,
288
289 #[arg(hide_short_help = true, long, global = true)]
291 no_cache: bool,
292
293 #[arg(hide_short_help = true, long, global = true)]
295 threads: Option<usize>,
296
297 #[arg(long, visible_alias = "base", global = true)]
299 changed_since: Option<String>,
300
301 #[arg(
306 hide_short_help = true,
307 long = "diff-file",
308 value_name = "PATH",
309 global = true
310 )]
311 diff_file: Option<PathBuf>,
312
313 #[arg(hide_short_help = true, long = "diff-stdin", global = true)]
316 diff_stdin: bool,
317
318 #[arg(
325 hide_short_help = true,
326 long = "churn-file",
327 value_name = "PATH",
328 global = true
329 )]
330 churn_file: Option<PathBuf>,
331
332 #[arg(
339 hide_short_help = true,
340 long = "max-file-size",
341 value_name = "MB",
342 global = true
343 )]
344 max_file_size: Option<u32>,
345
346 #[arg(hide_short_help = true, long, global = true)]
348 baseline: Option<PathBuf>,
349
350 #[arg(
365 hide_short_help = true,
366 long = "baseline-mode",
367 value_enum,
368 global = true
369 )]
370 baseline_mode: Option<BaselineModeArg>,
371
372 #[arg(long, global = true, value_name = "RUN_ID", hide = true)]
378 parent_run: Option<String>,
379
380 #[arg(hide_short_help = true, long, global = true)]
382 save_baseline: Option<PathBuf>,
383
384 #[arg(long, global = true)]
387 production: bool,
388
389 #[arg(
393 hide_short_help = true,
394 long = "no-production",
395 global = true,
396 conflicts_with = "production"
397 )]
398 no_production: bool,
399
400 #[arg(hide_short_help = true, long = "production-dead-code")]
402 production_dead_code: bool,
403
404 #[arg(hide_short_help = true, long = "production-health")]
406 production_health: bool,
407
408 #[arg(hide_short_help = true, long = "production-dupes")]
410 production_dupes: bool,
411
412 #[arg(short, long, global = true, value_delimiter = ',')]
416 workspace: Option<Vec<String>>,
417
418 #[arg(long, global = true, value_name = "REF")]
421 changed_workspaces: Option<String>,
422
423 #[arg(hide_short_help = true, long, global = true)]
425 group_by: Option<GroupBy>,
426
427 #[arg(hide_short_help = true, long, global = true)]
429 performance: bool,
430
431 #[arg(hide_short_help = true, long, global = true)]
433 explain: bool,
434
435 #[arg(hide_short_help = true, long, global = true)]
437 explain_skipped: bool,
438
439 #[arg(hide_short_help = true, long, global = true)]
441 summary: bool,
442
443 #[arg(long, global = true)]
445 ci: bool,
446
447 #[arg(hide_short_help = true, long, global = true)]
449 fail_on_issues: bool,
450
451 #[arg(hide_short_help = true, long, global = true, value_name = "PATH")]
453 sarif_file: Option<PathBuf>,
454
455 #[arg(short = 'o', long, global = true, value_name = "PATH")]
459 output_file: Option<PathBuf>,
460
461 #[arg(
471 hide_short_help = true,
472 long = "report-path-prefix",
473 visible_alias = "annotations-path-prefix",
474 global = true,
475 value_name = "PREFIX"
476 )]
477 report_path_prefix: Option<String>,
478
479 #[arg(hide_short_help = true, long, global = true)]
481 fail_on_regression: bool,
482
483 #[arg(
485 hide_short_help = true,
486 long,
487 global = true,
488 value_name = "TOLERANCE",
489 default_value = "0"
490 )]
491 tolerance: String,
492
493 #[arg(hide_short_help = true, long, global = true, value_name = "PATH")]
495 regression_baseline: Option<PathBuf>,
496
497 #[expect(
501 clippy::option_option,
502 reason = "clap pattern: None=not passed, Some(None)=flag only (write to config), Some(Some(path))=write to file"
503 )]
504 #[arg(hide_short_help = true, long, global = true, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
505 save_regression_baseline: Option<Option<String>>,
506
507 #[arg(long, value_delimiter = ',')]
509 only: Vec<AnalysisKind>,
510
511 #[arg(long, value_delimiter = ',')]
513 skip: Vec<AnalysisKind>,
514
515 #[arg(hide_short_help = true, long = "dupes-mode", global = true)]
517 dupes_mode: Option<DupesMode>,
518
519 #[arg(hide_short_help = true, long = "dupes-near", global = true)]
521 dupes_near: bool,
522
523 #[arg(hide_short_help = true, long = "dupes-threshold", global = true)]
525 dupes_threshold: Option<f64>,
526
527 #[arg(hide_short_help = true, long = "dupes-min-tokens", global = true)]
529 dupes_min_tokens: Option<usize>,
530
531 #[arg(hide_short_help = true, long = "dupes-min-lines", global = true)]
533 dupes_min_lines: Option<usize>,
534
535 #[arg(hide_short_help = true, long = "dupes-min-occurrences", global = true, value_parser = parse_min_occurrences)]
537 dupes_min_occurrences: Option<usize>,
538
539 #[arg(hide_short_help = true, long = "dupes-skip-local", global = true)]
541 dupes_skip_local: bool,
542
543 #[arg(hide_short_help = true, long = "dupes-cross-language", global = true)]
545 dupes_cross_language: bool,
546
547 #[arg(hide_short_help = true, long = "dupes-ignore-imports", global = true)]
550 dupes_ignore_imports: bool,
551
552 #[arg(
555 hide_short_help = true,
556 long = "dupes-no-ignore-imports",
557 global = true,
558 conflicts_with = "dupes_ignore_imports"
559 )]
560 dupes_no_ignore_imports: bool,
561
562 #[arg(hide_short_help = true, long)]
564 score: bool,
565
566 #[arg(hide_short_help = true, long)]
568 trend: bool,
569
570 #[expect(
573 clippy::option_option,
574 reason = "clap pattern: None=not passed, Some(None)=default path, Some(Some(path))=custom path"
575 )]
576 #[arg(hide_short_help = true, long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
577 save_snapshot: Option<Option<String>>,
578
579 #[arg(hide_short_help = true, long, value_name = "PATH")]
582 coverage: Option<PathBuf>,
583
584 #[arg(hide_short_help = true, long = "coverage-root", value_name = "PATH")]
587 coverage_root: Option<PathBuf>,
588
589 #[arg(hide_short_help = true, long, global = true)]
591 include_entry_exports: bool,
592
593 #[arg(hide_short_help = true, long, global = true)]
596 type_aware: bool,
597
598 #[arg(
601 hide_short_help = true,
602 long,
603 global = true,
604 conflicts_with = "type_aware"
605 )]
606 no_type_aware: bool,
607
608 #[arg(hide_short_help = true, long, global = true, value_name = "PATH", action = clap::ArgAction::Append)]
610 type_aware_project: Vec<PathBuf>,
611
612 #[arg(hide_short_help = true, long, global = true, value_enum)]
614 type_aware_require: Option<TypeAwareRequireArg>,
615}
616
617impl Cli {
618 const fn type_aware_override(&self) -> Option<bool> {
622 if self.no_type_aware {
623 Some(false)
624 } else if self.type_aware {
625 Some(true)
626 } else {
627 None
628 }
629 }
630}
631
632#[derive(Clone, Copy, Subcommand)]
633enum TypeAwareCli {
634 Status,
636}
637
638#[derive(Subcommand)]
639enum Command {
640 #[command(name = "dead-code", alias = "check")]
642 Check {
643 #[arg(long)]
645 unused_files: bool,
646
647 #[arg(long)]
649 unused_exports: bool,
650
651 #[arg(long)]
653 unused_deps: bool,
654
655 #[arg(long)]
657 unused_types: bool,
658
659 #[arg(long)]
661 private_type_leaks: bool,
662
663 #[arg(long)]
665 unused_enum_members: bool,
666
667 #[arg(long)]
669 unused_class_members: bool,
670
671 #[arg(long)]
673 unused_store_members: bool,
674
675 #[arg(long)]
677 unprovided_injects: bool,
678
679 #[arg(long)]
681 unrendered_components: bool,
682
683 #[arg(long)]
685 unused_component_props: bool,
686
687 #[arg(long)]
689 unused_component_emits: bool,
690
691 #[arg(long)]
693 unused_component_inputs: bool,
694
695 #[arg(long)]
697 unused_component_outputs: bool,
698
699 #[arg(long)]
701 unused_svelte_events: bool,
702
703 #[arg(long)]
705 unused_server_actions: bool,
706
707 #[arg(long)]
709 unused_load_data_keys: bool,
710
711 #[arg(long)]
713 unresolved_imports: bool,
714
715 #[arg(long)]
717 unlisted_deps: bool,
718
719 #[arg(long)]
721 duplicate_exports: bool,
722
723 #[arg(long)]
725 circular_deps: bool,
726
727 #[arg(long)]
729 re_export_cycles: bool,
730
731 #[arg(long)]
733 boundary_violations: bool,
734
735 #[arg(long)]
737 policy_violations: bool,
738
739 #[arg(long)]
741 stale_suppressions: bool,
742
743 #[arg(long)]
745 unused_catalog_entries: bool,
746
747 #[arg(long)]
749 empty_catalog_groups: bool,
750
751 #[arg(long)]
753 unresolved_catalog_references: bool,
754
755 #[arg(long)]
757 unused_dependency_overrides: bool,
758
759 #[arg(long)]
761 misconfigured_dependency_overrides: bool,
762
763 #[arg(long)]
765 include_dupes: bool,
766
767 #[arg(long, value_name = "FILE:EXPORT")]
769 trace: Option<String>,
770
771 #[arg(long, value_name = "PATH")]
773 trace_file: Option<String>,
774
775 #[arg(long, value_name = "PACKAGE")]
777 trace_dependency: Option<String>,
778
779 #[arg(long, value_name = "PATH")]
783 impact_closure: Option<String>,
784
785 #[arg(long, value_name = "FILE:EXPORT")]
787 symbol_impact: Option<String>,
788
789 #[arg(long)]
791 top: Option<usize>,
792
793 #[arg(long, value_name = "PATH")]
797 file: Vec<std::path::PathBuf>,
798 },
799
800 Watch {
802 #[arg(long)]
804 no_clear: bool,
805 },
806
807 TypeAware {
809 #[command(subcommand)]
810 subcommand: TypeAwareCli,
811 },
812
813 Inspect {
815 #[arg(
817 long,
818 value_name = "PATH",
819 conflicts_with = "symbol",
820 required_unless_present = "symbol"
821 )]
822 file: Option<String>,
823
824 #[arg(long, value_name = "FILE:EXPORT", conflicts_with = "file")]
826 symbol: Option<String>,
827
828 #[arg(long)]
833 symbol_chain: bool,
834
835 #[arg(long)]
838 churn: bool,
839 },
840
841 Trace {
850 #[arg(value_name = "FILE:SYMBOL")]
852 symbol: String,
853
854 #[arg(long)]
857 callers: bool,
858
859 #[arg(long)]
862 callees: bool,
863
864 #[arg(long, value_name = "N")]
867 depth: Option<u32>,
868 },
869
870 Fix {
885 #[arg(long)]
887 dry_run: bool,
888
889 #[arg(long, alias = "force")]
891 yes: bool,
892
893 #[arg(long)]
900 no_create_config: bool,
901 },
902
903 Init {
912 #[arg(long)]
914 toml: bool,
915
916 #[arg(long, conflicts_with_all = ["toml", "hooks", "branch"])]
918 agents: bool,
919
920 #[arg(long)]
924 hooks: bool,
925
926 #[arg(long, requires = "hooks")]
928 branch: Option<String>,
929
930 #[arg(long, conflicts_with_all = ["toml", "agents", "hooks", "branch"])]
934 decline: bool,
935 },
936
937 Hooks {
944 #[command(subcommand)]
945 subcommand: HooksCli,
946 },
947
948 Ci {
950 #[command(subcommand)]
951 subcommand: CiCli,
952 },
953
954 ConfigSchema,
956
957 PluginSchema,
959
960 PluginCheck,
962
963 RulePackSchema,
965
966 RulePack {
968 #[command(subcommand)]
969 subcommand: RulePackCli,
970 },
971
972 Guard {
974 #[arg(required = true, num_args = 1..)]
976 files: Vec<String>,
977 },
978
979 Config {
997 #[arg(long)]
999 path: bool,
1000 },
1001
1002 Recommend,
1010
1011 List {
1013 #[arg(long)]
1015 entry_points: bool,
1016
1017 #[arg(long)]
1019 files: bool,
1020
1021 #[arg(long)]
1023 plugins: bool,
1024
1025 #[arg(long)]
1027 boundaries: bool,
1028
1029 #[arg(long)]
1033 workspaces: bool,
1034 },
1035
1036 Workspaces,
1042
1043 Dupes {
1045 #[arg(long)]
1048 mode: Option<DupesMode>,
1049
1050 #[arg(long)]
1052 near: bool,
1053
1054 #[arg(long)]
1057 min_tokens: Option<usize>,
1058
1059 #[arg(long)]
1062 min_lines: Option<usize>,
1063
1064 #[arg(long, value_parser = parse_min_occurrences)]
1069 min_occurrences: Option<usize>,
1070
1071 #[arg(long)]
1074 threshold: Option<f64>,
1075
1076 #[arg(long)]
1078 skip_local: bool,
1079
1080 #[arg(long)]
1082 cross_language: bool,
1083
1084 #[arg(long)]
1088 ignore_imports: bool,
1089
1090 #[arg(long, conflicts_with = "ignore_imports")]
1093 no_ignore_imports: bool,
1094
1095 #[arg(long)]
1098 top: Option<usize>,
1099
1100 #[arg(long, value_name = "FILE:LINE")]
1102 trace: Option<String>,
1103 },
1104
1105 Health {
1111 #[arg(long)]
1113 max_cyclomatic: Option<u16>,
1114
1115 #[arg(long)]
1117 max_cognitive: Option<u16>,
1118
1119 #[arg(long)]
1123 max_crap: Option<f64>,
1124
1125 #[arg(long)]
1127 top: Option<usize>,
1128
1129 #[arg(long, default_value = "cyclomatic")]
1131 sort: SortBy,
1132
1133 #[arg(long)]
1136 complexity: bool,
1137
1138 #[arg(long)]
1145 complexity_breakdown: bool,
1146
1147 #[arg(long)]
1152 file_scores: bool,
1153
1154 #[arg(long)]
1157 coverage_gaps: bool,
1158
1159 #[arg(long)]
1162 hotspots: bool,
1163
1164 #[arg(long)]
1168 ownership: bool,
1169
1170 #[arg(long, value_name = "MODE", value_enum)]
1175 ownership_emails: Option<EmailModeArg>,
1176
1177 #[arg(long)]
1180 targets: bool,
1181
1182 #[arg(long)]
1185 type_coupling: bool,
1186
1187 #[arg(long)]
1192 css: bool,
1193
1194 #[arg(long, value_enum)]
1197 effort: Option<EffortFilter>,
1198
1199 #[arg(long)]
1202 score: bool,
1203
1204 #[arg(long, value_name = "N")]
1213 min_score: Option<f64>,
1214
1215 #[arg(long, value_name = "LEVEL", value_enum)]
1219 min_severity: Option<HealthSeverityCli>,
1220
1221 #[arg(long)]
1225 report_only: bool,
1226
1227 #[arg(long, value_name = "DURATION")]
1230 since: Option<String>,
1231
1232 #[arg(long, value_name = "N")]
1234 min_commits: Option<u32>,
1235
1236 #[expect(
1240 clippy::option_option,
1241 reason = "clap pattern: None=not passed, Some(None)=flag only, Some(Some(path))=with value"
1242 )]
1243 #[arg(long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
1244 save_snapshot: Option<Option<String>>,
1245
1246 #[arg(long)]
1250 trend: bool,
1251
1252 #[arg(long, value_name = "PATH")]
1261 coverage: Option<PathBuf>,
1262
1263 #[arg(long, value_name = "PATH")]
1269 coverage_root: Option<PathBuf>,
1270
1271 #[arg(long, value_name = "PATH")]
1275 runtime_coverage: Option<PathBuf>,
1276
1277 #[arg(long, default_value_t = 100)]
1279 min_invocations_hot: u64,
1280
1281 #[arg(long, value_name = "N")]
1287 min_observation_volume: Option<u32>,
1288
1289 #[arg(long, value_name = "RATIO")]
1294 low_traffic_threshold: Option<f64>,
1295 },
1296
1297 Flags {
1304 #[arg(long)]
1306 top: Option<usize>,
1307 },
1308
1309 Suppressions {
1319 #[arg(long, value_name = "PATH")]
1321 file: Vec<std::path::PathBuf>,
1322 },
1323
1324 Explain {
1330 #[arg(required = true, num_args = 1.., value_name = "ISSUE_TYPE")]
1332 issue_type: Vec<String>,
1333 },
1334
1335 #[command(visible_alias = "review")]
1360 Audit {
1361 #[arg(long = "production-dead-code")]
1363 production_dead_code: bool,
1364
1365 #[arg(long = "production-health")]
1367 production_health: bool,
1368
1369 #[arg(long = "production-dupes")]
1371 production_dupes: bool,
1372
1373 #[arg(long)]
1376 dead_code_baseline: Option<PathBuf>,
1377
1378 #[arg(long)]
1381 health_baseline: Option<PathBuf>,
1382
1383 #[arg(long)]
1386 dupes_baseline: Option<PathBuf>,
1387
1388 #[arg(long)]
1392 max_crap: Option<f64>,
1393
1394 #[arg(long, value_name = "PATH")]
1398 coverage: Option<PathBuf>,
1399
1400 #[arg(long, value_name = "PATH")]
1404 coverage_root: Option<PathBuf>,
1405
1406 #[arg(long = "no-css")]
1408 no_css: bool,
1409
1410 #[arg(long)]
1414 css_deep: bool,
1415
1416 #[arg(long = "no-css-deep")]
1418 no_css_deep: bool,
1419
1420 #[arg(long, value_enum)]
1426 gate: Option<AuditGateArg>,
1427
1428 #[arg(long, value_name = "PATH")]
1437 runtime_coverage: Option<PathBuf>,
1438
1439 #[arg(long, default_value_t = 100)]
1442 min_invocations_hot: u64,
1443
1444 #[arg(long, value_name = "MARKER", hide = true)]
1449 gate_marker: Option<String>,
1450
1451 #[arg(long)]
1457 brief: bool,
1458
1459 #[arg(
1464 long,
1465 value_name = "N",
1466 default_value_t = audit_decision_surface::DEFAULT_DECISION_CAP
1467 )]
1468 max_decisions: usize,
1469
1470 #[arg(long, conflicts_with_all = ["walkthrough_file", "walkthrough"])]
1478 walkthrough_guide: bool,
1479
1480 #[arg(long, value_name = "PATH")]
1488 walkthrough_file: Option<PathBuf>,
1489
1490 #[arg(long, conflicts_with_all = ["walkthrough_guide", "walkthrough_file"])]
1496 walkthrough: bool,
1497
1498 #[arg(long, value_name = "PATH")]
1504 mark_viewed: Vec<PathBuf>,
1505
1506 #[arg(long)]
1510 show_cleared: bool,
1511
1512 #[arg(long)]
1518 show_deprioritized: bool,
1519 },
1520
1521 AuditCache {
1523 #[command(subcommand)]
1524 subcommand: AuditCacheCli,
1525 },
1526
1527 DecisionSurface {
1539 #[arg(
1542 long,
1543 value_name = "N",
1544 default_value_t = audit_decision_surface::DEFAULT_DECISION_CAP
1545 )]
1546 max_decisions: usize,
1547 },
1548
1549 Impact {
1559 #[command(subcommand)]
1560 subcommand: Option<ImpactCli>,
1561 #[arg(long)]
1565 all: bool,
1566 #[arg(long, value_enum, default_value_t = ImpactSortCli::Recent)]
1568 sort: ImpactSortCli,
1569 #[arg(long)]
1572 limit: Option<usize>,
1573 },
1574
1575 Security {
1606 #[command(subcommand)]
1607 subcommand: Option<SecuritySubcommand>,
1608 #[arg(long, value_name = "PATH")]
1613 runtime_coverage: Option<PathBuf>,
1614 #[arg(long, default_value_t = 100)]
1617 min_invocations_hot: u64,
1618 #[arg(long, value_name = "PATH")]
1622 file: Vec<std::path::PathBuf>,
1623 #[arg(long, value_name = "MODE")]
1629 gate: Option<security::SecurityGateArg>,
1630 #[arg(long)]
1632 surface: bool,
1633 },
1634
1635 Report {
1640 #[arg(long, value_name = "PATH")]
1643 from: PathBuf,
1644 },
1645 Schema,
1647
1648 CiTemplate {
1655 #[command(subcommand)]
1656 subcommand: CiTemplateCli,
1657 },
1658
1659 Migrate {
1661 #[arg(long, conflicts_with = "jsonc")]
1663 toml: bool,
1664
1665 #[arg(long)]
1673 jsonc: bool,
1674
1675 #[arg(long)]
1677 dry_run: bool,
1678
1679 #[arg(long, value_name = "PATH")]
1681 from: Option<PathBuf>,
1682 },
1683
1684 License {
1691 #[command(subcommand)]
1692 subcommand: LicenseCli,
1693 },
1694
1695 Telemetry {
1703 #[command(subcommand)]
1704 subcommand: TelemetryCli,
1705 },
1706
1707 Coverage {
1713 #[command(subcommand)]
1714 subcommand: CoverageCli,
1715 },
1716
1717 SetupHooks {
1732 #[arg(long, value_enum)]
1734 agent: Option<setup_hooks::HookAgentArg>,
1735
1736 #[arg(long)]
1738 dry_run: bool,
1739
1740 #[arg(long)]
1743 force: bool,
1744
1745 #[arg(long)]
1747 user: bool,
1748
1749 #[arg(long)]
1751 gitignore_claude: bool,
1752
1753 #[arg(long)]
1757 uninstall: bool,
1758 },
1759
1760 Viz {
1762 #[arg(long = "out", value_name = "PATH")]
1764 output: Option<PathBuf>,
1765
1766 #[arg(long)]
1768 no_open: bool,
1769
1770 #[arg(long = "viz-format", default_value = "html")]
1772 viz_format: viz::VizFormat,
1773 },
1774}
1775
1776#[derive(Subcommand)]
1777enum SecuritySubcommand {
1778 Survivors {
1780 #[arg(long, value_name = "PATH")]
1782 candidates: PathBuf,
1783 #[arg(long, value_name = "PATH")]
1785 verdicts: PathBuf,
1786 #[arg(long)]
1788 require_verdict_for_each_candidate: bool,
1789 },
1790 #[command(name = "blind-spots")]
1792 BlindSpots {
1793 #[arg(long, value_name = "PATH")]
1795 file: Vec<PathBuf>,
1796 },
1797}
1798
1799#[derive(clap::Subcommand)]
1800enum AuditCacheCli {
1801 Remove {
1807 #[arg(long)]
1809 dry_run: bool,
1810
1811 #[arg(long, alias = "force")]
1813 yes: bool,
1814 },
1815
1816 Prune {
1829 #[arg(long)]
1831 dry_run: bool,
1832
1833 #[arg(long, value_name = "N")]
1840 max_age_days: Option<u32>,
1841 },
1842}
1843
1844#[derive(clap::Subcommand)]
1845enum LicenseCli {
1846 Activate {
1851 #[arg(value_name = "JWT")]
1853 jwt: Option<String>,
1854
1855 #[arg(long, value_name = "PATH")]
1857 from_file: Option<PathBuf>,
1858
1859 #[arg(long, conflicts_with_all = ["jwt", "from_file"])]
1861 stdin: bool,
1862
1863 #[arg(long, requires = "email")]
1870 trial: bool,
1871
1872 #[arg(long, value_name = "ADDR")]
1874 email: Option<String>,
1875 },
1876 Status,
1878 Refresh,
1880 Deactivate,
1882}
1883
1884#[derive(Clone, Copy, clap::Subcommand)]
1885enum TelemetryCli {
1886 Status,
1888 Enable,
1890 Disable,
1892 Inspect {
1894 #[arg(long)]
1896 example: bool,
1897 },
1898}
1899
1900#[derive(clap::Subcommand)]
1901enum CiTemplateCli {
1902 Gitlab {
1904 #[arg(long, value_name = "DIR", num_args = 0..=1, default_missing_value = ".")]
1908 vendor: Option<PathBuf>,
1909
1910 #[arg(long)]
1912 force: bool,
1913 },
1914}
1915
1916#[derive(clap::Subcommand)]
1917enum CoverageCli {
1918 Setup {
1920 #[arg(short = 'y', long)]
1922 yes: bool,
1923
1924 #[arg(long)]
1926 non_interactive: bool,
1927
1928 #[arg(long)]
1930 json: bool,
1931 },
1932 Analyze {
1938 #[arg(long, value_name = "PATH", conflicts_with = "cloud")]
1940 runtime_coverage: Option<PathBuf>,
1941
1942 #[arg(long, visible_alias = "runtime-coverage-cloud")]
1944 cloud: bool,
1945
1946 #[arg(long, value_name = "KEY")]
1948 api_key: Option<String>,
1949
1950 #[arg(long, value_name = "URL")]
1952 api_endpoint: Option<String>,
1953
1954 #[arg(long, value_name = "OWNER/REPO")]
1960 repo: Option<String>,
1961
1962 #[arg(long, value_name = "ID")]
1964 project_id: Option<String>,
1965
1966 #[arg(long, value_name = "DAYS", default_value_t = 30)]
1968 coverage_period: u16,
1969
1970 #[arg(long, value_name = "ENV")]
1972 environment: Option<String>,
1973
1974 #[arg(long, value_name = "SHA")]
1976 commit_sha: Option<String>,
1977
1978 #[arg(long)]
1980 production: bool,
1981
1982 #[arg(long, default_value_t = 100)]
1984 min_invocations_hot: u64,
1985
1986 #[arg(long, value_name = "N")]
1988 min_observation_volume: Option<u32>,
1989
1990 #[arg(long, value_name = "RATIO")]
1992 low_traffic_threshold: Option<f64>,
1993
1994 #[arg(long)]
1996 top: Option<usize>,
1997
1998 #[arg(long)]
2000 blast_radius: bool,
2001
2002 #[arg(long)]
2004 importance: bool,
2005 },
2006 UploadInventory {
2017 #[arg(long, value_name = "KEY")]
2026 api_key: Option<String>,
2027
2028 #[arg(long, value_name = "URL")]
2033 api_endpoint: Option<String>,
2034
2035 #[arg(long, value_name = "PROJECT_ID")]
2040 project_id: Option<String>,
2041
2042 #[arg(long, value_name = "SHA")]
2047 git_sha: Option<String>,
2048
2049 #[arg(long)]
2055 allow_dirty: bool,
2056
2057 #[arg(long, value_name = "GLOB", num_args = 0..)]
2061 exclude_paths: Vec<String>,
2062
2063 #[arg(long, value_name = "PREFIX")]
2076 path_prefix: Option<String>,
2077
2078 #[arg(long)]
2080 dry_run: bool,
2081
2082 #[arg(long)]
2088 with_callers: bool,
2089
2090 #[arg(long)]
2094 ignore_upload_errors: bool,
2095 },
2096 UploadSourceMaps {
2109 #[arg(long, value_name = "PATH", default_value = "dist")]
2111 dir: PathBuf,
2112
2113 #[arg(long, value_name = "GLOB", default_value = "**/*.map")]
2115 include: String,
2116
2117 #[arg(long, value_name = "GLOB", default_value = "**/node_modules/**")]
2121 exclude: Vec<String>,
2122
2123 #[arg(long, value_name = "NAME")]
2127 repo: Option<String>,
2128
2129 #[arg(long, value_name = "SHA")]
2134 git_sha: Option<String>,
2135
2136 #[arg(long, value_name = "URL")]
2138 endpoint: Option<String>,
2139
2140 #[arg(long, value_name = "BOOL", default_value_t = true, action = clap::ArgAction::Set)]
2145 strip_path: bool,
2146
2147 #[arg(long)]
2149 dry_run: bool,
2150
2151 #[arg(long, value_name = "N", default_value_t = 4)]
2153 concurrency: usize,
2154
2155 #[arg(long)]
2157 fail_fast: bool,
2158 },
2159 UploadStaticFindings {
2166 #[arg(long, value_name = "KEY")]
2176 api_key: Option<String>,
2177
2178 #[arg(long, value_name = "URL")]
2183 api_endpoint: Option<String>,
2184
2185 #[arg(long, value_name = "PROJECT_ID")]
2190 project_id: Option<String>,
2191
2192 #[arg(long, value_name = "SHA")]
2197 git_sha: Option<String>,
2198
2199 #[arg(long)]
2205 allow_dirty: bool,
2206
2207 #[arg(long)]
2209 dry_run: bool,
2210
2211 #[arg(long)]
2215 ignore_upload_errors: bool,
2216 },
2217}
2218
2219#[derive(Subcommand)]
2220enum CiCli {
2221 PlanPrComment {
2223 #[arg(long)]
2225 body: PathBuf,
2226
2227 #[arg(long)]
2229 marker_id: String,
2230
2231 #[arg(long)]
2233 clean: bool,
2234
2235 #[arg(long)]
2237 existing_comment_id: Option<String>,
2238
2239 #[arg(long)]
2241 existing_body: Option<PathBuf>,
2242 },
2243
2244 PostPrComment {
2246 #[arg(long, value_enum)]
2248 provider: CiProviderArg,
2249
2250 #[arg(long)]
2252 pr: Option<String>,
2253
2254 #[arg(long)]
2256 mr: Option<String>,
2257
2258 #[arg(long)]
2260 body: PathBuf,
2261
2262 #[arg(long)]
2264 envelope: Option<PathBuf>,
2265
2266 #[arg(long)]
2268 marker_id: String,
2269
2270 #[arg(long)]
2272 clean: bool,
2273
2274 #[arg(long)]
2276 repo: Option<String>,
2277
2278 #[arg(long = "project-id")]
2280 project_id: Option<String>,
2281
2282 #[arg(long = "api-url")]
2284 api_url: Option<String>,
2285
2286 #[arg(long)]
2288 dry_run: bool,
2289 },
2290
2291 PostReview {
2293 #[arg(long, value_enum)]
2295 provider: CiProviderArg,
2296
2297 #[arg(long)]
2299 pr: Option<String>,
2300
2301 #[arg(long)]
2303 mr: Option<String>,
2304
2305 #[arg(long)]
2307 envelope: PathBuf,
2308
2309 #[arg(long)]
2311 repo: Option<String>,
2312
2313 #[arg(long = "project-id")]
2315 project_id: Option<String>,
2316
2317 #[arg(long = "api-url")]
2319 api_url: Option<String>,
2320
2321 #[arg(long)]
2323 dry_run: bool,
2324 },
2325
2326 PostCheckRun {
2328 #[arg(long, value_enum)]
2330 provider: CiProviderArg,
2331
2332 #[arg(long)]
2334 decision: PathBuf,
2335
2336 #[arg(long)]
2338 repo: String,
2339
2340 #[arg(long = "head-sha")]
2342 head_sha: String,
2343
2344 #[arg(long = "api-url")]
2346 api_url: Option<String>,
2347
2348 #[arg(long = "split-gates")]
2350 split_gates: bool,
2351
2352 #[arg(long)]
2354 dry_run: bool,
2355 },
2356
2357 ReconcileReview {
2359 #[arg(long, value_enum)]
2361 provider: CiProviderArg,
2362
2363 #[arg(long)]
2365 pr: Option<String>,
2366
2367 #[arg(long)]
2369 mr: Option<String>,
2370
2371 #[arg(long)]
2373 envelope: PathBuf,
2374
2375 #[arg(long)]
2377 repo: Option<String>,
2378
2379 #[arg(long = "project-id")]
2381 project_id: Option<String>,
2382
2383 #[arg(long = "api-url")]
2385 api_url: Option<String>,
2386
2387 #[arg(long)]
2389 dry_run: bool,
2390 },
2391}
2392
2393#[derive(Subcommand)]
2394enum RulePackCli {
2395 Init {
2397 name: Option<String>,
2399
2400 #[arg(long, default_value = "starter")]
2402 template: String,
2403
2404 #[arg(long, default_value = "rule-packs")]
2406 dir: String,
2407
2408 #[arg(long)]
2410 no_config: bool,
2411 },
2412
2413 List,
2415
2416 Test {
2418 pack: Option<PathBuf>,
2420 },
2421
2422 Schema,
2424}
2425
2426#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
2428pub enum BaselineModeArg {
2429 #[default]
2431 Count,
2432 Identity,
2435}
2436
2437impl From<BaselineModeArg> for fallow_engine::baseline::HealthBaselineMode {
2438 fn from(value: BaselineModeArg) -> Self {
2439 match value {
2440 BaselineModeArg::Count => Self::Count,
2441 BaselineModeArg::Identity => Self::Identity,
2442 }
2443 }
2444}
2445
2446#[derive(Clone, Copy, Debug, clap::ValueEnum)]
2447enum CiProviderArg {
2448 Github,
2449 Gitlab,
2450}
2451
2452#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2454enum TypeAwareRequireArg {
2455 BestEffort,
2457 Complete,
2459}
2460
2461impl From<TypeAwareRequireArg> for fallow_config::TypeAwareRequire {
2462 fn from(value: TypeAwareRequireArg) -> Self {
2463 match value {
2464 TypeAwareRequireArg::BestEffort => Self::BestEffort,
2465 TypeAwareRequireArg::Complete => Self::Complete,
2466 }
2467 }
2468}
2469
2470#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2472pub enum EffortFilter {
2473 Low,
2474 Medium,
2475 High,
2476}
2477
2478impl EffortFilter {
2479 const fn to_estimate(self) -> fallow_output::EffortEstimate {
2481 match self {
2482 Self::Low => fallow_output::EffortEstimate::Low,
2483 Self::Medium => fallow_output::EffortEstimate::Medium,
2484 Self::High => fallow_output::EffortEstimate::High,
2485 }
2486 }
2487}
2488
2489#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2491pub enum HealthSeverityCli {
2492 Moderate,
2493 High,
2494 Critical,
2495}
2496
2497impl HealthSeverityCli {
2498 const fn to_health_severity(self) -> fallow_output::FindingSeverity {
2500 match self {
2501 Self::Moderate => fallow_output::FindingSeverity::Moderate,
2502 Self::High => fallow_output::FindingSeverity::High,
2503 Self::Critical => fallow_output::FindingSeverity::Critical,
2504 }
2505 }
2506}
2507
2508#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2514pub enum EmailModeArg {
2515 Raw,
2517 Handle,
2519 Anonymized,
2521 #[value(hide = true)]
2523 Hash,
2524}
2525
2526impl EmailModeArg {
2527 const fn to_config(self) -> fallow_config::EmailMode {
2529 match self {
2530 Self::Raw => fallow_config::EmailMode::Raw,
2531 Self::Handle => fallow_config::EmailMode::Handle,
2532 Self::Anonymized => fallow_config::EmailMode::Anonymized,
2533 Self::Hash => fallow_config::EmailMode::Hash,
2534 }
2535 }
2536}
2537
2538#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2540pub enum AuditGateArg {
2541 NewOnly,
2543 All,
2545}
2546
2547impl From<AuditGateArg> for fallow_config::AuditGate {
2548 fn from(value: AuditGateArg) -> Self {
2549 match value {
2550 AuditGateArg::NewOnly => Self::NewOnly,
2551 AuditGateArg::All => Self::All,
2552 }
2553 }
2554}
2555
2556fn parse_min_occurrences(s: &str) -> Result<usize, String> {
2560 let value: usize = s
2561 .parse()
2562 .map_err(|_| format!("`{s}` is not a non-negative integer"))?;
2563 if value < 2 {
2564 return Err(format!(
2565 "must be at least 2 (got {value}); a single occurrence isn't a duplicate"
2566 ));
2567 }
2568 Ok(value)
2569}
2570
2571fn resolve_audit_baseline_path(
2577 root: &std::path::Path,
2578 cli: Option<&std::path::Path>,
2579 config: Option<&str>,
2580) -> Option<PathBuf> {
2581 let path = cli.map(std::path::Path::to_path_buf).or_else(|| {
2582 config.map(|p| {
2583 let path = PathBuf::from(p);
2584 if path_util::is_absolute_path_any_platform(&path) {
2585 path
2586 } else {
2587 root.join(path)
2588 }
2589 })
2590 })?;
2591 if path_util::is_absolute_path_any_platform(&path) {
2592 Some(path)
2593 } else {
2594 Some(root.join(path))
2595 }
2596}
2597
2598fn emit_known_failure(
2599 message: &str,
2600 exit_code: u8,
2601 output: fallow_config::OutputFormat,
2602 reason: telemetry::FailureReason,
2603) -> ExitCode {
2604 telemetry::note_failure_reason(reason);
2605 emit_error(message, exit_code, output)
2606}
2607
2608fn emit_known_failure_with_style(
2609 message: &str,
2610 exit_code: u8,
2611 output: fallow_config::OutputFormat,
2612 json_style: json_style::JsonStyle,
2613 reason: telemetry::FailureReason,
2614) -> ExitCode {
2615 telemetry::note_failure_reason(reason);
2616 error::emit_error_with_style(message, exit_code, output, json_style)
2617}
2618
2619fn unsupported_security_global(cli: &Cli) -> Option<&'static str> {
2620 if cli.baseline.is_some() {
2621 Some("--baseline")
2622 } else if cli.save_baseline.is_some() {
2623 Some("--save-baseline")
2624 } else if cli.production {
2625 Some("--production")
2626 } else if cli.no_production {
2627 Some("--no-production")
2628 } else if cli.group_by.is_some() {
2629 Some("--group-by")
2630 } else if cli.performance {
2631 Some("--performance")
2632 } else if cli.explain_skipped {
2633 Some("--explain-skipped")
2634 } else if cli.fail_on_regression {
2635 Some("--fail-on-regression")
2636 } else if cli.regression_baseline.is_some() {
2637 Some("--regression-baseline")
2638 } else if cli.save_regression_baseline.is_some() {
2639 Some("--save-regression-baseline")
2640 } else if cli.dupes_mode.is_some() {
2641 Some("--dupes-mode")
2642 } else if cli.dupes_threshold.is_some() {
2643 Some("--dupes-threshold")
2644 } else if cli.dupes_min_tokens.is_some() {
2645 Some("--dupes-min-tokens")
2646 } else if cli.dupes_min_lines.is_some() {
2647 Some("--dupes-min-lines")
2648 } else if cli.dupes_min_occurrences.is_some() {
2649 Some("--dupes-min-occurrences")
2650 } else if cli.dupes_skip_local {
2651 Some("--dupes-skip-local")
2652 } else if cli.dupes_cross_language {
2653 Some("--dupes-cross-language")
2654 } else if cli.dupes_ignore_imports {
2655 Some("--dupes-ignore-imports")
2656 } else if cli.dupes_no_ignore_imports {
2657 Some("--dupes-no-ignore-imports")
2658 } else if cli.include_entry_exports {
2659 Some("--include-entry-exports")
2660 } else {
2661 None
2662 }
2663}
2664
2665struct DispatchContext<'a> {
2666 cli: &'a Cli,
2667 root: &'a std::path::Path,
2668 output: fallow_config::OutputFormat,
2669 quiet: bool,
2670 fail_on_issues: bool,
2671 json_style: json_style::JsonStyle,
2672 threads: usize,
2673 tolerance: regression::Tolerance,
2674 save_regression_file: Option<&'a std::path::PathBuf>,
2675 save_to_config: bool,
2676}
2677
2678impl DispatchContext<'_> {
2679 fn production_modes(
2680 &self,
2681 dead_code: bool,
2682 health: bool,
2683 dupes: bool,
2684 ) -> Result<ProductionModes, ExitCode> {
2685 resolve_production_modes(self.cli, self.root, self.output, dead_code, health, dupes)
2686 }
2687
2688 fn production_for(
2689 &self,
2690 analysis: fallow_config::ProductionAnalysis,
2691 ) -> Result<bool, ExitCode> {
2692 self.production_modes(false, false, false)
2693 .map(|modes| modes.for_analysis(analysis))
2694 }
2695
2696 fn regression_opts(&self, scoped: bool) -> regression::RegressionOpts<'_> {
2697 regression::RegressionOpts {
2698 fail_on_regression: self.cli.fail_on_regression,
2699 tolerance: self.tolerance,
2700 regression_baseline_file: self.cli.regression_baseline.as_deref(),
2701 save_target: if let Some(path) = self.save_regression_file {
2702 regression::SaveRegressionTarget::File(path)
2703 } else if self.save_to_config {
2704 regression::SaveRegressionTarget::Config
2705 } else {
2706 regression::SaveRegressionTarget::None
2707 },
2708 scoped,
2709 quiet: self.quiet,
2710 output: self.output,
2711 }
2712 }
2713}
2714
2715#[cfg(unix)]
2730fn signal_test_helper() -> ExitCode {
2731 use std::io::Write as _;
2732 use std::process::Command;
2733
2734 if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER_GRACEFUL").is_some() {
2735 signal::set_graceful_mode();
2736 }
2737
2738 let mut command = Command::new("sleep");
2739 command.arg("30");
2740 let child = match signal::ScopedChild::spawn(&mut command) {
2741 Ok(c) => c,
2742 Err(err) => {
2743 let _ = writeln!(std::io::stderr(), "spawn sleep failed: {err}");
2744 return ExitCode::from(2);
2745 }
2746 };
2747 let pid = child.id();
2748 let stdout = std::io::stdout();
2749 let mut lock = stdout.lock();
2750 let _ = writeln!(lock, "{pid}");
2751 let _ = lock.flush();
2752 drop(lock);
2753 let _ = child.wait_with_output();
2754 if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER_GRACEFUL").is_some() {
2755 return ExitCode::SUCCESS;
2756 }
2757 std::thread::sleep(std::time::Duration::from_secs(5));
2758 ExitCode::SUCCESS
2759}
2760
2761#[cfg(not(unix))]
2762fn signal_test_helper() -> ExitCode {
2763 ExitCode::from(2)
2764}
2765
2766fn install_spawn_hooks() {
2767 fallow_engine::churn::set_spawn_hook(signal::scoped_child::output);
2768 fallow_engine::changed_files::set_spawn_hook(signal::scoped_child::output);
2769}
2770
2771fn install_signal_handlers() {
2772 if let Err(err) = signal::install_handlers() {
2773 use std::io::Write as _;
2774 let stderr = std::io::stderr();
2775 let mut lock = stderr.lock();
2776 let _ = writeln!(lock, "fallow: failed to install signal handlers: {err}");
2777 }
2778}
2779
2780fn redirect_report_to_file(
2785 path: &std::path::Path,
2786 output: fallow_config::OutputFormat,
2787) -> Result<(), ExitCode> {
2788 if let Some(parent) = path.parent()
2789 && !parent.as_os_str().is_empty()
2790 && let Err(e) = std::fs::create_dir_all(parent)
2791 {
2792 return Err(emit_error(
2793 &format!(
2794 "failed to create {} for --output-file: {e}",
2795 parent.display()
2796 ),
2797 2,
2798 output,
2799 ));
2800 }
2801 match std::fs::File::create(path) {
2802 Ok(file) => {
2803 report::sink::set_file_sink(file);
2804 colored::control::set_override(false);
2805 Ok(())
2806 }
2807 Err(e) => Err(emit_error(
2808 &format!("failed to open {} for --output-file: {e}", path.display()),
2809 2,
2810 output,
2811 )),
2812 }
2813}
2814
2815fn finalize_report_file(
2818 path: &std::path::Path,
2819 quiet: bool,
2820 output: fallow_config::OutputFormat,
2821) -> Result<(), ExitCode> {
2822 if let Err(e) = report::sink::flush() {
2823 return Err(emit_error(
2824 &format!("failed to write {}: {e}", path.display()),
2825 2,
2826 output,
2827 ));
2828 }
2829 if !quiet && report::sink::wrote() {
2833 eprintln!("Report written to {}", path.display());
2834 }
2835 Ok(())
2836}
2837
2838pub fn run() -> ExitCode {
2843 install_signal_handlers();
2844 install_spawn_hooks();
2845
2846 if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER").is_some() {
2847 return signal_test_helper();
2848 }
2849
2850 let (mut cli, fmt) = match parse_cli_args() {
2851 Ok(parsed) => parsed,
2852 Err(code) => return code,
2853 };
2854 if cli.pretty && !fmt.payload_is_json {
2855 eprintln!(
2856 "Error: --pretty requires JSON output. Use --format json --pretty, or remove --pretty."
2857 );
2858 return ExitCode::from(2);
2859 }
2860
2861 if let Some(code) = run_schema_command_if_requested(&cli, fmt.json_style) {
2862 return code;
2863 }
2864
2865 if let Some(code) = run_telemetry_command_if_requested(&mut cli, fmt.output, fmt.json_style) {
2866 return code;
2867 }
2868 if is_impact_statusline(&cli) {
2869 let (root, _) = match validate_inputs(&cli, fmt.output, fmt.json_style) {
2870 Ok(validated) => validated,
2871 Err(code) => return code,
2872 };
2873 return cli_impact::render_impact_statusline(&root);
2874 }
2875 let telemetry_run = start_telemetry_run(&cli, &fmt);
2876
2877 let (root, threads) = match validate_inputs(&cli, fmt.output, fmt.json_style) {
2878 Ok(v) => v,
2879 Err(code) => {
2880 return record_run_epilogue(telemetry_run, code, None, cli.parent_run.as_deref());
2881 }
2882 };
2883
2884 let FormatConfig {
2885 output,
2886 payload_is_json: _,
2887 quiet,
2888 fail_on_issues,
2889 json_style,
2890 } = fmt;
2891
2892 let tolerance =
2893 match run_pre_dispatch_checks(&cli, &root, output, json_style, quiet, telemetry_run) {
2894 Ok(tolerance) => tolerance,
2895 Err(code) => return code,
2896 };
2897
2898 let (save_regression_file, save_to_config) = regression_save_targets(&cli);
2899
2900 let command = cli.command.take();
2901 let dispatch = DispatchContext {
2902 cli: &cli,
2903 root: &root,
2904 output,
2905 quiet,
2906 fail_on_issues,
2907 json_style,
2908 threads,
2909 tolerance,
2910 save_regression_file: save_regression_file.as_ref(),
2911 save_to_config,
2912 };
2913 let exit_code = match dispatch_and_finalize(&dispatch, command) {
2914 Ok(code) => code,
2915 Err(code) => return code,
2916 };
2917 record_run_epilogue(telemetry_run, exit_code, None, cli.parent_run.as_deref())
2918}
2919
2920#[doc(hidden)]
2924pub fn benchmark_fix_dry_run(root: &Path, threads: usize) -> (ExitCode, usize) {
2925 let config_path = None;
2926 fix::run_fix_with_count(&fix::FixOptions {
2927 root,
2928 config_path: &config_path,
2929 output: fallow_config::OutputFormat::Json,
2930 json_style: json_style::JsonStyle::Compact,
2931 no_cache: true,
2932 threads,
2933 quiet: true,
2934 emit_output: false,
2935 allow_remote_extends: false,
2936 dry_run: true,
2937 yes: false,
2938 production: false,
2939 no_create_config: true,
2940 type_aware: None,
2941 type_aware_projects: &[],
2942 type_aware_require: None,
2943 })
2944}
2945
2946#[doc(hidden)]
2949pub use audit::AuditReviewBenchmarkCorpus;
2950
2951#[doc(hidden)]
2954pub fn create_audit_review_benchmark_corpus(
2955 root: &Path,
2956 changed_files: &[PathBuf],
2957 threads: usize,
2958) -> Result<AuditReviewBenchmarkCorpus, ExitCode> {
2959 audit::create_audit_review_benchmark_corpus(root, changed_files, threads)
2960}
2961
2962#[doc(hidden)]
2965pub fn benchmark_audit_review_brief_many_changed_files_json(
2966 corpus: &mut AuditReviewBenchmarkCorpus,
2967) -> (ExitCode, usize, usize, usize, usize, usize) {
2968 match audit::benchmark_audit_review_brief_many_changed_files_json(corpus) {
2969 Ok(result) => (
2970 ExitCode::SUCCESS,
2971 result.introduced_count,
2972 result.inherited_count,
2973 result.public_api_added_count,
2974 result.decision_count,
2975 result.rendered_bytes,
2976 ),
2977 Err(code) => (code, 0, 0, 0, 0, 0),
2978 }
2979}
2980
2981#[doc(hidden)]
2982pub use inspect::InspectBenchmarkCorpus;
2983
2984#[doc(hidden)]
2987pub fn create_inspect_benchmark_corpus(root: &Path, threads: usize) -> InspectBenchmarkCorpus {
2988 inspect::create_inspect_benchmark_corpus(root, threads)
2989}
2990
2991#[doc(hidden)]
2994pub fn benchmark_inspect_file_evidence_bundle_json(
2995 root: &Path,
2996 threads: usize,
2997 corpus: &InspectBenchmarkCorpus,
2998) -> (ExitCode, usize, usize) {
2999 match inspect::benchmark_inspect_file_evidence_bundle_json(root, threads, corpus) {
3000 Ok((child_call_count, rendered_bytes)) => {
3001 (ExitCode::SUCCESS, child_call_count, rendered_bytes)
3002 }
3003 Err(_) => (ExitCode::from(2), 0, 0),
3004 }
3005}
3006
3007#[doc(hidden)]
3010pub fn benchmark_dead_code_json(root: &Path, threads: usize) -> (ExitCode, usize, usize) {
3011 match check::benchmark_dead_code_json(root, threads) {
3012 Ok((issue_count, rendered_bytes)) => (ExitCode::SUCCESS, issue_count, rendered_bytes),
3013 Err(code) => (code, 0, 0),
3014 }
3015}
3016
3017#[doc(hidden)]
3020pub fn benchmark_security_json(root: &Path, threads: usize) -> (ExitCode, usize, usize) {
3021 match security::benchmark_security_json(root, threads) {
3022 Ok((finding_count, rendered_bytes)) => (ExitCode::SUCCESS, finding_count, rendered_bytes),
3023 Err(code) => (code, 0, 0),
3024 }
3025}
3026
3027#[doc(hidden)]
3028pub use security::{SecurityBlindSpotsBenchmarkResult, SecuritySurvivorsBenchmarkCorpus};
3029
3030#[doc(hidden)]
3033pub fn create_security_survivors_benchmark_corpus(
3034 root: &Path,
3035 threads: usize,
3036) -> Result<SecuritySurvivorsBenchmarkCorpus, ExitCode> {
3037 security::create_security_survivors_benchmark_corpus(root, threads)
3038}
3039
3040#[doc(hidden)]
3043pub fn benchmark_security_survivors_json(
3044 corpus: &SecuritySurvivorsBenchmarkCorpus,
3045) -> (ExitCode, usize, usize, usize, usize, usize) {
3046 match security::benchmark_security_survivors_json(corpus) {
3047 Ok((survivors, dismissed, needs_human_review, unverdicted, rendered_bytes)) => (
3048 ExitCode::SUCCESS,
3049 survivors,
3050 dismissed,
3051 needs_human_review,
3052 unverdicted,
3053 rendered_bytes,
3054 ),
3055 Err(_) => (ExitCode::from(2), 0, 0, 0, 0, 0),
3056 }
3057}
3058
3059#[doc(hidden)]
3062pub fn benchmark_security_blind_spots_json(
3063 root: &Path,
3064 diagnostics: &[fallow_types::results::SecurityUnresolvedCalleeDiagnostic],
3065) -> SecurityBlindSpotsBenchmarkResult {
3066 security::benchmark_security_blind_spots_json(root, diagnostics)
3067}
3068
3069#[doc(hidden)]
3072pub fn benchmark_list_json(root: &Path, threads: usize) -> (ExitCode, usize, usize, usize, usize) {
3073 match list::benchmark_list_json(root, threads) {
3074 Ok((file_count, entry_point_count, workspace_count, rendered_bytes)) => (
3075 ExitCode::SUCCESS,
3076 file_count,
3077 entry_point_count,
3078 workspace_count,
3079 rendered_bytes,
3080 ),
3081 Err(code) => (code, 0, 0, 0, 0),
3082 }
3083}
3084
3085#[doc(hidden)]
3088pub fn benchmark_list_boundaries_json(
3089 root: &Path,
3090 threads: usize,
3091) -> (ExitCode, usize, usize, usize, usize) {
3092 match list::benchmark_list_boundaries_json(root, threads) {
3093 Ok((zone_count, rule_count, matched_file_count, rendered_bytes)) => (
3094 ExitCode::SUCCESS,
3095 zone_count,
3096 rule_count,
3097 matched_file_count,
3098 rendered_bytes,
3099 ),
3100 Err(code) => (code, 0, 0, 0, 0),
3101 }
3102}
3103
3104#[doc(hidden)]
3107pub use watch::WatchFilterBenchmarkGlobalGitignore;
3108
3109#[doc(hidden)]
3112pub fn create_watch_filter_benchmark_global_gitignore() -> WatchFilterBenchmarkGlobalGitignore {
3113 watch::create_benchmark_global_gitignore()
3114}
3115
3116#[doc(hidden)]
3119pub fn benchmark_watch_filter_initialization(
3120 config: &fallow_config::ResolvedConfig,
3121 global_gitignore: &WatchFilterBenchmarkGlobalGitignore,
3122) -> (usize, usize) {
3123 watch::benchmark_filter_initialization(config, global_gitignore)
3124}
3125
3126#[doc(hidden)]
3129pub fn benchmark_viz_html(root: &Path, threads: usize) -> (ExitCode, usize, usize, usize) {
3130 match viz::benchmark_viz_html(root, threads) {
3131 Ok((file_count, edge_count, rendered_bytes)) => {
3132 (ExitCode::SUCCESS, file_count, edge_count, rendered_bytes)
3133 }
3134 Err(code) => (code, 0, 0, 0),
3135 }
3136}
3137
3138#[doc(hidden)]
3141pub fn benchmark_rule_pack_test_json(root: &Path, threads: usize) -> (ExitCode, usize, usize) {
3142 match rule_pack::benchmark_rule_pack_test_json(root, threads) {
3143 Ok((finding_count, rendered_bytes)) => (ExitCode::SUCCESS, finding_count, rendered_bytes),
3144 Err(code) => (code, 0, 0),
3145 }
3146}
3147
3148#[doc(hidden)]
3151pub fn benchmark_recommend_json(root: &Path) -> (ExitCode, usize, usize, bool, usize) {
3152 match onboarding::benchmark_recommend_json(root) {
3153 Ok((decision_count, framework_count, heterogeneous, rendered_bytes)) => (
3154 ExitCode::SUCCESS,
3155 decision_count,
3156 framework_count,
3157 heterogeneous,
3158 rendered_bytes,
3159 ),
3160 Err(_) => (ExitCode::from(2), 0, 0, false, 0),
3161 }
3162}
3163
3164#[doc(hidden)]
3167pub fn benchmark_runtime_coverage_analyze_json(
3168 root: &Path,
3169 runtime_coverage_path: &Path,
3170 response_bytes: &[u8],
3171 threads: usize,
3172) -> (ExitCode, usize, usize, usize, String) {
3173 match coverage::benchmark_local_json(root, runtime_coverage_path, response_bytes, threads) {
3174 Ok((finding_count, hot_path_count, request_bytes, rendered)) => (
3175 ExitCode::SUCCESS,
3176 finding_count,
3177 hot_path_count,
3178 request_bytes,
3179 rendered,
3180 ),
3181 Err(code) => (code, 0, 0, 0, String::new()),
3182 }
3183}
3184
3185fn is_impact_statusline(cli: &Cli) -> bool {
3188 matches!(
3189 cli.command.as_ref(),
3190 Some(Command::Impact {
3191 subcommand: Some(ImpactCli::Statusline),
3192 all: false,
3193 ..
3194 })
3195 )
3196}
3197
3198fn dispatch_and_finalize(
3202 dispatch: &DispatchContext<'_>,
3203 command: Option<Command>,
3204) -> Result<ExitCode, ExitCode> {
3205 let cli = dispatch.cli;
3206 let output = dispatch.output;
3207 let quiet = dispatch.quiet;
3208
3209 if let Some(path) = cli.output_file.as_deref()
3212 && let Err(code) = redirect_report_to_file(path, output)
3213 {
3214 return Err(code);
3215 }
3216
3217 let exit_code = if command.is_some() && cli_has_bare_coverage_input(cli) {
3218 emit_error(bare_coverage_subcommand_error_message(), 2, output)
3219 } else {
3220 match command {
3221 None => dispatch_bare_command(dispatch),
3222 Some(cmd) => dispatch_subcommand(cmd, dispatch),
3223 }
3224 };
3225
3226 if let Some(path) = cli.output_file.as_deref()
3227 && let Err(code) = finalize_report_file(path, quiet, output)
3228 {
3229 return Err(code);
3230 }
3231 Ok(exit_code)
3232}
3233
3234fn run_telemetry_command_if_requested(
3235 cli: &mut Cli,
3236 output: fallow_config::OutputFormat,
3237 json_style: json_style::JsonStyle,
3238) -> Option<ExitCode> {
3239 if matches!(cli.command, Some(Command::Telemetry { .. }))
3240 && let Some(Command::Telemetry { subcommand }) = cli.command.take()
3241 {
3242 return Some(telemetry::run(
3243 map_telemetry_subcommand(subcommand),
3244 output,
3245 json_style,
3246 ));
3247 }
3248 None
3249}
3250
3251fn run_schema_command_if_requested(
3252 cli: &Cli,
3253 json_style: json_style::JsonStyle,
3254) -> Option<ExitCode> {
3255 match cli.command {
3256 Some(Command::Schema) => Some(schema::run_schema(json_style)),
3257 Some(Command::ConfigSchema) => Some(init::run_config_schema(json_style)),
3258 Some(Command::PluginSchema) => Some(init::run_plugin_schema(json_style)),
3259 Some(Command::RulePackSchema) => Some(init::run_rule_pack_schema(json_style)),
3260 _ => None,
3261 }
3262}
3263
3264fn regression_save_targets(cli: &Cli) -> (Option<std::path::PathBuf>, bool) {
3265 let save_file = cli.save_regression_baseline.as_ref().and_then(|opt| {
3266 opt.as_ref()
3267 .filter(|path| !path.is_empty())
3268 .map(std::path::PathBuf::from)
3269 });
3270 let save_to_config = cli.save_regression_baseline.is_some() && save_file.is_none();
3271 (save_file, save_to_config)
3272}
3273
3274fn dispatch_bare_command(dispatch: &DispatchContext<'_>) -> ExitCode {
3275 let cli = dispatch.cli;
3276 let (run_check, run_dupes, run_health) = combined::resolve_analyses(&cli.only, &cli.skip);
3277 let production = match dispatch.production_modes(
3278 cli.production_dead_code,
3279 cli.production_health,
3280 cli.production_dupes,
3281 ) {
3282 Ok(production) => production,
3283 Err(code) => return code,
3284 };
3285 let coverage_inputs = if run_health {
3290 match resolve_health_coverage_inputs(
3291 dispatch,
3292 cli.coverage.as_deref(),
3293 cli.coverage_root.as_deref(),
3294 ) {
3295 Ok(inputs) => inputs,
3296 Err(code) => return code,
3297 }
3298 } else {
3299 ResolvedHealthCoverageInputs::default()
3300 };
3301 run_bare_combined(
3302 dispatch,
3303 production,
3304 &coverage_inputs,
3305 BareAnalyses {
3306 run_check,
3307 run_dupes,
3308 run_health,
3309 },
3310 )
3311}
3312
3313#[derive(Clone, Copy)]
3315struct BareAnalyses {
3316 run_check: bool,
3317 run_dupes: bool,
3318 run_health: bool,
3319}
3320
3321fn run_bare_combined(
3324 dispatch: &DispatchContext<'_>,
3325 production: ProductionModes,
3326 coverage_inputs: &ResolvedHealthCoverageInputs,
3327 analyses: BareAnalyses,
3328) -> ExitCode {
3329 let cli = dispatch.cli;
3330 let (output, quiet, fail_on_issues) =
3331 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
3332 combined::run_combined(&combined::CombinedOptions {
3333 root: dispatch.root,
3334 config_path: &cli.config,
3335 output,
3336 json_style: dispatch.json_style,
3337 no_cache: cli.no_cache,
3338 threads: dispatch.threads,
3339 quiet,
3340 allow_remote_extends: cli.allow_remote_extends,
3341 fail_on_issues,
3342 sarif_file: cli.sarif_file.as_deref(),
3343 changed_since: cli.changed_since.as_deref(),
3344 churn_file: cli.churn_file.as_deref(),
3345 baseline: cli.baseline.as_deref(),
3346 save_baseline: cli.save_baseline.as_deref(),
3347 production: cli.production,
3348 production_dead_code: Some(production.dead_code),
3349 production_health: Some(production.health),
3350 production_dupes: Some(production.dupes),
3351 workspace: cli.workspace.as_deref(),
3352 changed_workspaces: cli.changed_workspaces.as_deref(),
3353 group_by: cli.group_by,
3354 type_aware: cli.type_aware_override(),
3355 type_aware_projects: &cli.type_aware_project,
3356 type_aware_require: cli.type_aware_require.map(Into::into),
3357 explain: cli.explain,
3358 explain_skipped: cli.explain_skipped,
3359 performance: cli.performance,
3360 summary: cli.summary,
3361 run_check: analyses.run_check,
3362 run_dupes: analyses.run_dupes,
3363 run_health: analyses.run_health,
3364 dupes_mode: cli.dupes_mode,
3365 dupes_near: cli.dupes_near,
3366 dupes_threshold: cli.dupes_threshold,
3367 dupes_min_tokens: cli.dupes_min_tokens,
3368 dupes_min_lines: cli.dupes_min_lines,
3369 dupes_min_occurrences: cli.dupes_min_occurrences,
3370 dupes_skip_local: cli.dupes_skip_local,
3371 dupes_cross_language: cli.dupes_cross_language,
3372 dupes_ignore_imports: resolve_ignore_imports(
3373 cli.dupes_ignore_imports,
3374 cli.dupes_no_ignore_imports,
3375 ),
3376 score: cli.score || cli.trend,
3377 trend: cli.trend,
3378 save_snapshot: cli.save_snapshot.as_ref(),
3379 coverage: coverage_inputs.coverage.as_deref(),
3380 coverage_root: coverage_inputs.coverage_root.as_deref(),
3381 include_entry_exports: cli.include_entry_exports,
3382 regression_opts: dispatch.regression_opts(
3383 cli.changed_since.is_some()
3384 || cli.workspace.is_some()
3385 || cli.changed_workspaces.is_some(),
3386 ),
3387 })
3388}
3389
3390fn dispatch_subcommand(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3391 let cli = dispatch.cli;
3392 let root = dispatch.root;
3393 let output = dispatch.output;
3394 let quiet = dispatch.quiet;
3395 match command {
3396 check @ Command::Check { .. } => dispatch_check_command(check, dispatch),
3397 Command::Watch { no_clear } => dispatch_watch(dispatch, no_clear),
3398 Command::TypeAware { subcommand } => dispatch_type_aware_command(dispatch, subcommand),
3399 Command::Inspect {
3400 file,
3401 symbol,
3402 symbol_chain,
3403 churn,
3404 } => dispatch_inspect_command(dispatch, file, symbol, symbol_chain, churn),
3405 Command::Trace {
3406 symbol,
3407 callers,
3408 callees,
3409 depth,
3410 } => dispatch_trace_command(dispatch, symbol, callers, callees, depth),
3411 fix @ Command::Fix { .. } => dispatch_fix_command(&fix, dispatch),
3412 init @ Command::Init { .. } => dispatch_init_command(init, root, quiet),
3413 Command::Hooks { subcommand } => {
3414 run_hooks_command(root, subcommand, output, dispatch.json_style)
3415 }
3416 Command::Ci { subcommand } => {
3417 ci::run(map_ci_subcommand(subcommand), output, dispatch.json_style)
3418 }
3419 Command::ConfigSchema => init::run_config_schema(dispatch.json_style),
3420 Command::PluginSchema => init::run_plugin_schema(dispatch.json_style),
3421 Command::PluginCheck => plugin_check::run_plugin_check(root, output, dispatch.json_style),
3422 Command::RulePackSchema => init::run_rule_pack_schema(dispatch.json_style),
3423 Command::RulePack { subcommand } => dispatch_rule_pack_command(dispatch, subcommand),
3424 Command::Guard { files } => dispatch_guard_command(dispatch, &files),
3425 Command::CiTemplate { subcommand } => dispatch_ci_template_command(subcommand),
3426 Command::Config { path } => config::run_config_with_options(config::RunConfigInput {
3427 root,
3428 explicit_config: cli.config.as_deref(),
3429 path_only: path,
3430 output,
3431 quiet,
3432 json_style: dispatch.json_style,
3433 load_options: fallow_config::ConfigLoadOptions {
3434 allow_remote_extends: cli.allow_remote_extends,
3435 },
3436 }),
3437 Command::Recommend => onboarding::run_recommend(root, output, dispatch.json_style),
3438 list @ (Command::Workspaces | Command::List { .. }) => {
3439 dispatch_list_command(&list, dispatch)
3440 }
3441 dupes @ Command::Dupes { .. } => dispatch_dupes_command(dupes, dispatch),
3442 health @ Command::Health { .. } => dispatch_health_command(health, dispatch),
3443 Command::Flags { top } => dispatch_flags_command(dispatch, top),
3444 Command::Suppressions { file } => dispatch_suppressions_command(dispatch, &file),
3445 Command::Explain { issue_type } => {
3446 explain::run_explain(&issue_type.join(" "), output, dispatch.json_style)
3447 }
3448 audit @ Command::Audit { .. } => dispatch_audit_command(audit, dispatch),
3449 Command::AuditCache { subcommand } => dispatch_audit_cache_command(dispatch, &subcommand),
3450 Command::DecisionSurface { max_decisions } => {
3451 dispatch_decision_surface(dispatch, max_decisions)
3452 }
3453 Command::Impact {
3454 subcommand,
3455 all,
3456 sort,
3457 limit,
3458 } => dispatch_impact(
3459 root,
3460 quiet,
3461 output,
3462 dispatch.json_style,
3463 subcommand,
3464 ImpactCrossRepoOpts { all, sort, limit },
3465 ),
3466 security @ Command::Security { .. } => dispatch_security_command(security, dispatch),
3467 Command::Viz {
3468 output: viz_output,
3469 no_open,
3470 viz_format,
3471 } => dispatch_viz(dispatch, viz_output.as_deref(), no_open, viz_format),
3472 Command::Report { from } => {
3473 cli_report::run_report(&from, output, root, cli.config.as_deref())
3474 }
3475 Command::Schema => unreachable!("handled above"),
3476 migrate @ Command::Migrate { .. } => dispatch_migrate_command(migrate, root),
3477 Command::License { subcommand } => {
3478 dispatch_license_command(subcommand, output, dispatch.json_style)
3479 }
3480 Command::Telemetry { .. } => unreachable!("handled before root validation"),
3481 Command::Coverage { subcommand } => dispatch_coverage_command(dispatch, &subcommand),
3482 setup_hooks @ Command::SetupHooks { .. } => {
3483 dispatch_setup_hooks_command(&setup_hooks, dispatch)
3484 }
3485 }
3486}
3487
3488fn dispatch_type_aware_command(
3489 dispatch: &DispatchContext<'_>,
3490 subcommand: TypeAwareCli,
3491) -> ExitCode {
3492 match subcommand {
3493 TypeAwareCli::Status => {
3494 let status = fallow_api::type_aware_status(dispatch.root);
3495 match dispatch.output {
3496 fallow_config::OutputFormat::Json => {
3497 let output = type_aware_status_output(dispatch.root, status);
3498 match fallow_output::serialize_type_aware_status_json_output(
3499 output,
3500 crate::output_runtime::current_root_envelope_mode(),
3501 ) {
3502 Ok(value) => match dispatch.json_style.serialize(&value) {
3503 Ok(json) => {
3504 crate::report::sink::outln!("{json}");
3505 ExitCode::SUCCESS
3506 }
3507 Err(error) => emit_error(
3508 &format!("failed to serialize type-aware status: {error}"),
3509 2,
3510 dispatch.output,
3511 ),
3512 },
3513 Err(error) => emit_error(
3514 &format!("failed to build type-aware status: {error}"),
3515 2,
3516 dispatch.output,
3517 ),
3518 }
3519 }
3520 fallow_config::OutputFormat::Human => {
3521 if status.available {
3522 crate::report::sink::outln!(
3523 "{}",
3524 report::human_status_line(
3525 report::HumanStatus::Ok,
3526 format_args!(
3527 "Type-aware companion: available ({}, protocol {}, TypeScript {})",
3528 status.package_version.as_deref().unwrap_or("unknown"),
3529 status.protocol_version,
3530 status.backend_version.as_deref().unwrap_or("unknown"),
3531 )
3532 )
3533 );
3534 } else {
3535 crate::report::sink::outln!(
3536 "{}",
3537 report::human_status_line(
3538 report::HumanStatus::Inactive,
3539 "Type-aware companion: unavailable"
3540 )
3541 );
3542 if let Some(remediation) = status.remediation {
3543 crate::report::sink::outln!(
3544 "{}",
3545 report::human_status_line(
3546 report::HumanStatus::Warning,
3547 format_args!("Action: {remediation}")
3548 )
3549 );
3550 }
3551 }
3552 ExitCode::SUCCESS
3553 }
3554 _ => emit_error(
3555 "type-aware status supports human and json output",
3556 2,
3557 dispatch.output,
3558 ),
3559 }
3560 }
3561 }
3562}
3563
3564fn type_aware_status_output(
3565 root: &Path,
3566 status: fallow_api::TypeAwareStatus,
3567) -> fallow_output::TypeAwareStatusOutput {
3568 let companion_path = status.companion_path.as_deref().map(|path| {
3569 if let Ok(relative) = path.strip_prefix(root)
3570 && !relative.as_os_str().is_empty()
3571 {
3572 relative.to_string_lossy().replace('\\', "/")
3573 } else {
3574 path.file_name()
3575 .unwrap_or(path.as_os_str())
3576 .to_string_lossy()
3577 .into_owned()
3578 }
3579 });
3580 let remediation = status.remediation.map(|message| {
3581 let without_root = message.replace(root.to_string_lossy().as_ref(), ".");
3582 status.companion_path.as_deref().map_or_else(
3583 || without_root.clone(),
3584 |path| {
3585 without_root.replace(
3586 path.to_string_lossy().as_ref(),
3587 companion_path.as_deref().unwrap_or("fallow-type-aware"),
3588 )
3589 },
3590 )
3591 });
3592 fallow_output::TypeAwareStatusOutput {
3593 schema_version: fallow_types::envelope::SchemaVersion(
3594 fallow_output::TYPE_AWARE_STATUS_SCHEMA_VERSION,
3595 ),
3596 version: fallow_types::envelope::ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
3597 available: status.available,
3598 discovery_source: status.discovery_source.map(str::to_string),
3599 companion_path,
3600 package_version: status.package_version,
3601 protocol_version: status.protocol_version,
3602 backend_family: status.backend_family,
3603 backend_version: status.backend_version,
3604 remediation,
3605 }
3606}
3607
3608fn dispatch_check_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3610 let filters = check_issue_filters(&command);
3611 let Command::Check {
3612 include_dupes,
3613 trace,
3614 trace_file,
3615 trace_dependency,
3616 impact_closure,
3617 symbol_impact,
3618 top,
3619 file,
3620 ..
3621 } = command
3622 else {
3623 unreachable!("check dispatcher only handles check commands");
3624 };
3625
3626 dispatch_check(
3627 dispatch,
3628 &CheckDispatchArgs {
3629 filters,
3630 trace_opts: TraceOptions {
3631 trace_export: trace,
3632 trace_file,
3633 trace_dependency,
3634 impact_closure,
3635 symbol_impact,
3636 performance: dispatch.cli.performance,
3637 },
3638 include_dupes,
3639 type_aware: dispatch.cli.type_aware_override(),
3640 type_aware_project: dispatch.cli.type_aware_project.clone(),
3641 type_aware_require: dispatch.cli.type_aware_require,
3642 top,
3643 file,
3644 },
3645 )
3646}
3647
3648fn check_issue_filters(command: &Command) -> IssueFilters {
3653 check_issue_filters_framework(command, &check_issue_filters_core(command))
3654}
3655
3656fn check_issue_filters_core(command: &Command) -> IssueFilters {
3659 let Command::Check {
3660 unused_files,
3661 unused_exports,
3662 unused_deps,
3663 unused_types,
3664 private_type_leaks,
3665 unused_enum_members,
3666 unused_class_members,
3667 unresolved_imports,
3668 unlisted_deps,
3669 duplicate_exports,
3670 circular_deps,
3671 re_export_cycles,
3672 boundary_violations,
3673 policy_violations,
3674 stale_suppressions,
3675 ..
3676 } = command
3677 else {
3678 unreachable!("check filter builder only handles check commands");
3679 };
3680
3681 let mut filters = IssueFilters::default();
3682 for (flag, active) in [
3683 ("--unused-files", *unused_files),
3684 ("--unused-exports", *unused_exports),
3685 ("--unused-deps", *unused_deps),
3686 ("--unused-types", *unused_types),
3687 ("--private-type-leaks", *private_type_leaks),
3688 ("--unused-enum-members", *unused_enum_members),
3689 ("--unused-class-members", *unused_class_members),
3690 ("--unresolved-imports", *unresolved_imports),
3691 ("--unlisted-deps", *unlisted_deps),
3692 ("--duplicate-exports", *duplicate_exports),
3693 ("--circular-deps", *circular_deps),
3694 ("--re-export-cycles", *re_export_cycles),
3695 ("--boundary-violations", *boundary_violations),
3696 ("--policy-violations", *policy_violations),
3697 ("--stale-suppressions", *stale_suppressions),
3698 ] {
3699 enable_check_filter(&mut filters, flag, active);
3700 }
3701 filters
3702}
3703
3704fn check_issue_filters_framework(command: &Command, base: &IssueFilters) -> IssueFilters {
3707 let Command::Check {
3708 unused_store_members,
3709 unprovided_injects,
3710 unrendered_components,
3711 unused_component_props,
3712 unused_component_emits,
3713 unused_component_inputs,
3714 unused_component_outputs,
3715 unused_svelte_events,
3716 unused_server_actions,
3717 unused_load_data_keys,
3718 unused_catalog_entries,
3719 empty_catalog_groups,
3720 unresolved_catalog_references,
3721 unused_dependency_overrides,
3722 misconfigured_dependency_overrides,
3723 ..
3724 } = command
3725 else {
3726 unreachable!("check filter builder only handles check commands");
3727 };
3728
3729 let mut filters = base.clone();
3730 for (flag, active) in [
3731 ("--unused-store-members", *unused_store_members),
3732 ("--unprovided-injects", *unprovided_injects),
3733 ("--unrendered-components", *unrendered_components),
3734 ("--unused-component-props", *unused_component_props),
3735 ("--unused-component-emits", *unused_component_emits),
3736 ("--unused-component-inputs", *unused_component_inputs),
3737 ("--unused-component-outputs", *unused_component_outputs),
3738 ("--unused-svelte-events", *unused_svelte_events),
3739 ("--unused-server-actions", *unused_server_actions),
3740 ("--unused-load-data-keys", *unused_load_data_keys),
3741 ("--unused-catalog-entries", *unused_catalog_entries),
3742 ("--empty-catalog-groups", *empty_catalog_groups),
3743 (
3744 "--unresolved-catalog-references",
3745 *unresolved_catalog_references,
3746 ),
3747 (
3748 "--unused-dependency-overrides",
3749 *unused_dependency_overrides,
3750 ),
3751 (
3752 "--misconfigured-dependency-overrides",
3753 *misconfigured_dependency_overrides,
3754 ),
3755 ] {
3756 enable_check_filter(&mut filters, flag, active);
3757 }
3758 filters
3759}
3760
3761fn enable_check_filter(filters: &mut IssueFilters, flag: &str, active: bool) {
3762 if active {
3763 assert!(
3764 filters.enable_cli_filter_flag(flag),
3765 "check command uses unregistered dead-code filter flag {flag}"
3766 );
3767 }
3768}
3769
3770fn dispatch_inspect_command(
3771 dispatch: &DispatchContext<'_>,
3772 file: Option<String>,
3773 symbol: Option<String>,
3774 symbol_chain: bool,
3775 churn: bool,
3776) -> ExitCode {
3777 let target = match (file, symbol) {
3778 (Some(file), None) => inspect::InspectTarget::File { file },
3779 (None, Some(symbol)) => match symbol.rsplit_once(':') {
3780 Some((file, export_name))
3781 if !file.trim().is_empty() && !export_name.trim().is_empty() =>
3782 {
3783 inspect::InspectTarget::Symbol {
3784 file: file.to_string(),
3785 export_name: export_name.to_string(),
3786 }
3787 }
3788 _ => {
3789 return emit_error(
3790 "--symbol must be formatted as FILE:EXPORT",
3791 2,
3792 dispatch.output,
3793 );
3794 }
3795 },
3796 _ => {
3797 return emit_error(
3798 "inspect requires exactly one of --file or --symbol",
3799 2,
3800 dispatch.output,
3801 );
3802 }
3803 };
3804
3805 let churn_config = if churn {
3806 match load_config_for_analysis(
3807 dispatch.root,
3808 &dispatch.cli.config,
3809 ConfigLoadOptions {
3810 output: dispatch.output,
3811 no_cache: dispatch.cli.no_cache,
3812 threads: dispatch.threads,
3813 production_override: None,
3814 quiet: dispatch.quiet,
3815 allow_remote_extends: dispatch.cli.allow_remote_extends,
3816 },
3817 fallow_config::ProductionAnalysis::Health,
3818 ) {
3819 Ok(config) => Some(config),
3820 Err(code) => return code,
3821 }
3822 } else {
3823 None
3824 };
3825
3826 inspect::run_inspect(&inspect::InspectOptions {
3827 root: dispatch.root,
3828 config_path: dispatch.cli.config.as_ref(),
3829 output: dispatch.output,
3830 json_style: dispatch.json_style,
3831 no_cache: dispatch.cli.no_cache,
3832 no_production: dispatch.cli.no_production,
3833 max_file_size: dispatch.cli.max_file_size,
3834 threads: dispatch.threads,
3835 quiet: dispatch.quiet,
3836 production: dispatch.cli.production,
3837 workspace: dispatch.cli.workspace.as_ref(),
3838 target,
3839 churn_cache_dir: churn_config
3840 .as_ref()
3841 .map(|config| config.cache_dir.as_path()),
3842 symbol_chain,
3843 type_aware: dispatch.cli.type_aware_override(),
3844 type_aware_projects: &dispatch.cli.type_aware_project,
3845 type_aware_require: dispatch.cli.type_aware_require.map(Into::into),
3846 })
3847}
3848
3849fn dispatch_trace_command(
3850 dispatch: &DispatchContext<'_>,
3851 symbol: String,
3852 callers: bool,
3853 callees: bool,
3854 depth: Option<u32>,
3855) -> ExitCode {
3856 trace_chain::run_trace(&trace_chain::TraceChainOptions {
3857 root: dispatch.root,
3858 config_path: &dispatch.cli.config,
3859 output: dispatch.output,
3860 json_style: dispatch.json_style,
3861 no_cache: dispatch.cli.no_cache,
3862 threads: dispatch.threads,
3863 quiet: dispatch.quiet,
3864 allow_remote_extends: dispatch.cli.allow_remote_extends,
3865 target: symbol,
3866 callers,
3867 callees,
3868 depth: depth.unwrap_or(fallow_types::trace_chain::DEFAULT_TRACE_DEPTH),
3869 })
3870}
3871
3872fn dispatch_security_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3873 let Command::Security {
3874 subcommand,
3875 runtime_coverage,
3876 min_invocations_hot,
3877 file,
3878 gate,
3879 surface,
3880 } = command
3881 else {
3882 unreachable!("security dispatcher only handles security commands");
3883 };
3884
3885 let gate = gate.map(security::SecurityGateArg::into_mode);
3886 let cli = dispatch.cli;
3887 let (output, _quiet, fail_on_issues) =
3888 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
3889 let derived_flags = SecurityDerivedFlagState {
3890 output,
3891 json_style: dispatch.json_style,
3892 ci: cli.ci,
3893 fail_on_issues,
3894 sarif_file: cli.sarif_file.as_deref(),
3895 summary: cli.summary,
3896 explain: cli.explain,
3897 runtime_coverage: runtime_coverage.as_deref(),
3898 min_invocations_hot,
3899 file: file.as_slice(),
3900 gate,
3901 surface,
3902 };
3903 if let Some(code) = try_run_security_survivors(subcommand.as_ref(), &derived_flags) {
3904 return code;
3905 }
3906
3907 let scoped_files = scoped_security_files(&file, subcommand.as_ref());
3908 run_security_blind_spots_or_default(
3909 dispatch,
3910 &SecurityRunInputs {
3911 scoped_files: &scoped_files,
3912 subcommand: &subcommand,
3913 runtime_coverage: runtime_coverage.as_deref(),
3914 min_invocations_hot,
3915 gate,
3916 surface,
3917 },
3918 &derived_flags,
3919 )
3920}
3921
3922struct SecurityRunInputs<'a> {
3925 scoped_files: &'a [PathBuf],
3926 subcommand: &'a Option<SecuritySubcommand>,
3927 runtime_coverage: Option<&'a Path>,
3928 min_invocations_hot: u64,
3929 gate: Option<security::SecurityGateMode>,
3930 surface: bool,
3931}
3932
3933fn run_security_blind_spots_or_default(
3935 dispatch: &DispatchContext<'_>,
3936 inputs: &SecurityRunInputs<'_>,
3937 derived_flags: &SecurityDerivedFlagState<'_>,
3938) -> ExitCode {
3939 let cli = dispatch.cli;
3940 let (output, quiet, fail_on_issues) =
3941 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
3942 let opts = security::SecurityOptions {
3943 root: dispatch.root,
3944 config_path: &cli.config,
3945 output,
3946 json_style: dispatch.json_style,
3947 no_cache: cli.no_cache,
3948 threads: dispatch.threads,
3949 quiet,
3950 allow_remote_extends: cli.allow_remote_extends,
3951 fail_on_issues,
3952 sarif_file: cli.sarif_file.as_deref(),
3953 summary: cli.summary,
3954 changed_since: cli.changed_since.as_deref(),
3955 use_shared_diff_index: true,
3956 workspace: cli.workspace.as_deref(),
3957 changed_workspaces: cli.changed_workspaces.as_deref(),
3958 file: inputs.scoped_files,
3959 surface: inputs.surface,
3960 gate: inputs.gate,
3961 runtime_coverage: inputs.runtime_coverage,
3962 min_invocations_hot: inputs.min_invocations_hot,
3963 explain: cli.explain,
3964 };
3965 if matches!(
3966 inputs.subcommand,
3967 Some(SecuritySubcommand::BlindSpots { .. })
3968 ) {
3969 if let Some(code) = validate_security_blind_spots_flags(derived_flags) {
3970 return code;
3971 }
3972 security::run_blind_spots(&opts)
3973 } else {
3974 security::run(&opts)
3975 }
3976}
3977
3978fn try_run_security_survivors(
3981 subcommand: Option<&SecuritySubcommand>,
3982 flags: &SecurityDerivedFlagState<'_>,
3983) -> Option<ExitCode> {
3984 let Some(SecuritySubcommand::Survivors {
3985 candidates,
3986 verdicts,
3987 require_verdict_for_each_candidate,
3988 }) = subcommand
3989 else {
3990 return None;
3991 };
3992 if let Some(code) = validate_security_survivors_flags(flags) {
3993 return Some(code);
3994 }
3995 Some(security::run_survivors(
3996 &security::SecuritySurvivorsOptions {
3997 output: flags.output,
3998 json_style: flags.json_style,
3999 candidates,
4000 verdicts,
4001 require_verdict_for_each_candidate: *require_verdict_for_each_candidate,
4002 },
4003 ))
4004}
4005
4006fn scoped_security_files(
4008 file: &[PathBuf],
4009 subcommand: Option<&SecuritySubcommand>,
4010) -> Vec<PathBuf> {
4011 let mut scoped_files = file.to_vec();
4012 if let Some(SecuritySubcommand::BlindSpots {
4013 file: blind_spot_files,
4014 }) = subcommand
4015 {
4016 scoped_files.extend(blind_spot_files.iter().cloned());
4017 }
4018 scoped_files
4019}
4020
4021struct SecurityDerivedFlagState<'a> {
4022 output: fallow_config::OutputFormat,
4023 json_style: json_style::JsonStyle,
4024 ci: bool,
4025 fail_on_issues: bool,
4026 sarif_file: Option<&'a Path>,
4027 summary: bool,
4028 explain: bool,
4029 runtime_coverage: Option<&'a Path>,
4030 min_invocations_hot: u64,
4031 file: &'a [PathBuf],
4032 gate: Option<security::SecurityGateMode>,
4033 surface: bool,
4034}
4035
4036fn validate_security_survivors_flags(flags: &SecurityDerivedFlagState<'_>) -> Option<ExitCode> {
4037 let flag = if flags.ci {
4038 Some("--ci")
4039 } else if flags.fail_on_issues {
4040 Some("--fail-on-issues")
4041 } else if flags.sarif_file.is_some() {
4042 Some("--sarif-file")
4043 } else if flags.summary {
4044 Some("--summary")
4045 } else if flags.explain {
4046 Some("--explain")
4047 } else if flags.runtime_coverage.is_some() {
4048 Some("--runtime-coverage")
4049 } else if flags.min_invocations_hot != DEFAULT_MIN_INVOCATIONS_HOT {
4050 Some("--min-invocations-hot")
4051 } else if !flags.file.is_empty() {
4052 Some("--file")
4053 } else if flags.gate.is_some() {
4054 Some("--gate")
4055 } else if flags.surface {
4056 Some("--surface")
4057 } else {
4058 None
4059 }?;
4060 Some(emit_error(
4061 &format!("{flag} is not valid with `fallow security survivors`."),
4062 2,
4063 flags.output,
4064 ))
4065}
4066
4067fn validate_security_blind_spots_flags(flags: &SecurityDerivedFlagState<'_>) -> Option<ExitCode> {
4068 let flag = if flags.ci {
4069 Some("--ci")
4070 } else if flags.fail_on_issues {
4071 Some("--fail-on-issues")
4072 } else if flags.sarif_file.is_some() {
4073 Some("--sarif-file")
4074 } else if flags.summary {
4075 Some("--summary")
4076 } else if flags.explain {
4077 Some("--explain")
4078 } else if flags.runtime_coverage.is_some() {
4079 Some("--runtime-coverage")
4080 } else if flags.min_invocations_hot != DEFAULT_MIN_INVOCATIONS_HOT {
4081 Some("--min-invocations-hot")
4082 } else if flags.gate.is_some() {
4083 Some("--gate")
4084 } else if flags.surface {
4085 Some("--surface")
4086 } else {
4087 None
4088 }?;
4089 Some(emit_error(
4090 &format!("{flag} is not valid with `fallow security blind-spots`."),
4091 2,
4092 flags.output,
4093 ))
4094}
4095
4096fn dispatch_dupes_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4097 let Command::Dupes {
4098 mode,
4099 near,
4100 min_tokens,
4101 min_lines,
4102 min_occurrences,
4103 threshold,
4104 skip_local,
4105 cross_language,
4106 ignore_imports,
4107 no_ignore_imports,
4108 top,
4109 trace,
4110 } = command
4111 else {
4112 unreachable!("dupes dispatcher only handles dupes commands");
4113 };
4114
4115 dispatch_dupes(
4116 dispatch,
4117 &DupesDispatchArgs {
4118 mode,
4119 near,
4120 min_tokens,
4121 min_lines,
4122 min_occurrences,
4123 threshold,
4124 skip_local,
4125 cross_language,
4126 ignore_imports,
4127 no_ignore_imports,
4128 top,
4129 trace,
4130 },
4131 )
4132}
4133
4134fn dispatch_init_command(command: Command, root: &Path, quiet: bool) -> ExitCode {
4135 let Command::Init {
4136 toml,
4137 agents,
4138 hooks,
4139 branch,
4140 decline,
4141 } = command
4142 else {
4143 unreachable!("init dispatcher only handles init commands");
4144 };
4145
4146 init::run_init(&init::InitOptions {
4147 root,
4148 use_toml: toml,
4149 agents,
4150 hooks,
4151 branch: branch.as_deref(),
4152 decline,
4153 quiet,
4154 })
4155}
4156
4157fn dispatch_fix_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4158 let Command::Fix {
4159 dry_run,
4160 yes,
4161 no_create_config,
4162 } = command
4163 else {
4164 unreachable!("fix dispatcher only handles fix commands");
4165 };
4166
4167 dispatch_fix(
4168 dispatch,
4169 FixDispatchArgs {
4170 dry_run: *dry_run,
4171 yes: *yes,
4172 no_create_config: *no_create_config,
4173 },
4174 )
4175}
4176
4177fn dispatch_list_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4178 match command {
4179 Command::Workspaces => dispatch_list(dispatch, ListDispatchArgs::workspaces()),
4180 Command::List {
4181 entry_points,
4182 files,
4183 plugins,
4184 boundaries,
4185 workspaces,
4186 } => dispatch_list(
4187 dispatch,
4188 ListDispatchArgs {
4189 entry_points: *entry_points,
4190 files: *files,
4191 plugins: *plugins,
4192 boundaries: *boundaries,
4193 workspaces: *workspaces,
4194 },
4195 ),
4196 _ => unreachable!("list dispatcher only handles list commands"),
4197 }
4198}
4199
4200fn dispatch_migrate_command(command: Command, root: &Path) -> ExitCode {
4201 let Command::Migrate {
4202 toml,
4203 jsonc,
4204 dry_run,
4205 from,
4206 } = command
4207 else {
4208 unreachable!("migrate dispatcher only handles migrate commands");
4209 };
4210
4211 migrate::run_migrate(root, toml, jsonc, dry_run, from.as_deref())
4212}
4213
4214fn dispatch_license_command(
4215 subcommand: LicenseCli,
4216 output: fallow_config::OutputFormat,
4217 json_style: json_style::JsonStyle,
4218) -> ExitCode {
4219 license::run(&map_license_subcommand(subcommand), output, json_style)
4220}
4221
4222fn dispatch_ci_template_command(subcommand: CiTemplateCli) -> ExitCode {
4223 match subcommand {
4224 CiTemplateCli::Gitlab { vendor, force } => {
4225 ci_template::run_gitlab_template(&ci_template::GitlabTemplateOptions {
4226 vendor_dir: vendor,
4227 force,
4228 })
4229 }
4230 }
4231}
4232
4233fn dispatch_coverage_command(dispatch: &DispatchContext<'_>, subcommand: &CoverageCli) -> ExitCode {
4234 let cli = dispatch.cli;
4235 coverage::run(
4236 map_coverage_subcommand(subcommand, cli.explain),
4237 &coverage::RunContext {
4238 root: dispatch.root,
4239 config_path: &cli.config,
4240 output: dispatch.output,
4241 json_style: dispatch.json_style,
4242 quiet: dispatch.quiet,
4243 no_cache: cli.no_cache,
4244 threads: dispatch.threads,
4245 explain: cli.explain,
4246 allow_remote_extends: cli.allow_remote_extends,
4247 },
4248 )
4249}
4250
4251fn dispatch_health_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4252 let Command::Health {
4253 max_cyclomatic,
4254 max_cognitive,
4255 max_crap,
4256 top,
4257 sort,
4258 complexity,
4259 complexity_breakdown,
4260 file_scores,
4261 coverage_gaps,
4262 hotspots,
4263 ownership,
4264 ownership_emails,
4265 targets,
4266 type_coupling,
4267 css,
4268 effort,
4269 score,
4270 min_score,
4271 min_severity,
4272 report_only,
4273 since,
4274 min_commits,
4275 save_snapshot,
4276 trend,
4277 coverage,
4278 coverage_root,
4279 runtime_coverage,
4280 min_invocations_hot,
4281 min_observation_volume,
4282 low_traffic_threshold,
4283 } = command
4284 else {
4285 unreachable!("health dispatcher only handles health commands");
4286 };
4287
4288 let ownership = ownership || ownership_emails.is_some();
4289 let hotspots = hotspots || ownership;
4290 let args = HealthDispatchArgs {
4291 max_cyclomatic,
4292 max_cognitive,
4293 max_crap,
4294 top,
4295 sort,
4296 complexity,
4297 complexity_breakdown,
4298 file_scores,
4299 coverage_gaps,
4300 hotspots,
4301 ownership,
4302 ownership_emails: ownership_emails.map(EmailModeArg::to_config),
4303 targets,
4304 type_coupling,
4305 css,
4306 effort,
4307 score,
4308 min_score,
4309 min_severity: min_severity.map(HealthSeverityCli::to_health_severity),
4310 report_only,
4311 since: since.as_deref(),
4312 min_commits,
4313 save_snapshot: save_snapshot.as_ref(),
4314 trend,
4315 coverage: coverage.as_deref(),
4316 coverage_root: coverage_root.as_deref(),
4317 runtime_coverage: runtime_coverage.as_deref(),
4318 min_invocations_hot,
4319 min_observation_volume,
4320 low_traffic_threshold,
4321 };
4322 dispatch_health(dispatch, &args)
4323}
4324
4325fn dispatch_setup_hooks_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4326 let Command::SetupHooks {
4327 agent,
4328 dry_run,
4329 force,
4330 user,
4331 gitignore_claude,
4332 uninstall,
4333 } = command
4334 else {
4335 unreachable!("setup-hooks dispatcher only handles setup-hooks commands");
4336 };
4337
4338 setup_hooks::run_setup_hooks(&setup_hooks::SetupHooksOptions {
4339 root: dispatch.root,
4340 agent: *agent,
4341 dry_run: *dry_run,
4342 force: *force,
4343 user: *user,
4344 gitignore_claude: *gitignore_claude,
4345 uninstall: *uninstall,
4346 })
4347}
4348
4349fn dispatch_audit_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4350 let Command::Audit {
4351 production_dead_code,
4352 production_health,
4353 production_dupes,
4354 dead_code_baseline,
4355 health_baseline,
4356 dupes_baseline,
4357 max_crap,
4358 coverage,
4359 coverage_root,
4360 no_css,
4361 css_deep,
4362 no_css_deep,
4363 gate,
4364 runtime_coverage,
4365 min_invocations_hot,
4366 gate_marker,
4367 brief,
4368 max_decisions,
4369 walkthrough_guide,
4370 walkthrough_file,
4371 walkthrough,
4372 mark_viewed,
4373 show_cleared,
4374 show_deprioritized,
4375 } = command
4376 else {
4377 unreachable!("audit dispatcher only handles audit commands");
4378 };
4379
4380 let brief = brief || walkthrough_guide || walkthrough || walkthrough_file.is_some();
4383
4384 dispatch_audit(
4385 dispatch,
4386 &AuditDispatchArgs {
4387 production_dead_code,
4388 production_health,
4389 production_dupes,
4390 dead_code_baseline,
4391 health_baseline,
4392 dupes_baseline,
4393 max_crap,
4394 coverage,
4395 coverage_root,
4396 no_css,
4397 css_deep,
4398 no_css_deep,
4399 gate,
4400 runtime_coverage,
4401 min_invocations_hot,
4402 gate_marker,
4403 brief,
4404 max_decisions,
4405 walkthrough_guide,
4406 walkthrough_file,
4407 walkthrough,
4408 mark_viewed,
4409 show_cleared,
4410 show_deprioritized,
4411 },
4412 )
4413}
4414
4415fn dispatch_audit_cache_command(
4416 dispatch: &DispatchContext<'_>,
4417 subcommand: &AuditCacheCli,
4418) -> ExitCode {
4419 match subcommand {
4420 AuditCacheCli::Remove { dry_run, yes } => {
4421 if !*dry_run && !*yes && !std::io::stdin().is_terminal() {
4422 return emit_error(
4423 "audit-cache remove requires --yes (or --force) in non-interactive environments. Use --dry-run to preview removal first, then pass --yes to confirm.",
4424 2,
4425 dispatch.output,
4426 );
4427 }
4428 match base_worktree::remove_reusable_audit_caches(dispatch.root, *dry_run) {
4429 Ok(report) => {
4430 let action = if *dry_run { "would remove" } else { "removed" };
4431 if matches!(dispatch.output, fallow_config::OutputFormat::Json) {
4432 let value = serde_json::json!({
4433 "kind": "audit-cache-remove",
4434 "schema_version": 1,
4435 "command": "audit-cache remove",
4436 "root": dispatch.root,
4437 "dry_run": report.dry_run,
4438 "found": report.found,
4439 "would_remove": report.found.saturating_sub(report.skipped),
4440 "removed": report.removed,
4441 "skipped": report.skipped,
4442 "complete": report.skipped == 0,
4443 });
4444 let output_code = report::emit_report_json(
4445 &value,
4446 "audit cache removal",
4447 dispatch.json_style,
4448 );
4449 if output_code != ExitCode::SUCCESS {
4450 return output_code;
4451 }
4452 } else if !dispatch.quiet {
4453 println!(
4454 "audit cache: {action} {}, skipped {} for {}",
4455 if *dry_run {
4456 report.found.saturating_sub(report.skipped)
4457 } else {
4458 report.removed
4459 },
4460 report.skipped,
4461 dispatch.root.display(),
4462 );
4463 }
4464 if report.skipped == 0 {
4465 ExitCode::SUCCESS
4466 } else {
4467 ExitCode::from(2)
4468 }
4469 }
4470 Err(error) => emit_error(
4471 &format!(
4472 "failed to remove audit caches for {}: {error}",
4473 dispatch.root.display()
4474 ),
4475 2,
4476 dispatch.output,
4477 ),
4478 }
4479 }
4480 AuditCacheCli::Prune {
4481 dry_run,
4482 max_age_days,
4483 } => audit_cache_prune::run_audit_cache_prune(&audit_cache_prune::AuditCachePruneOptions {
4484 root: dispatch.root,
4485 config_path: dispatch.cli.config.as_ref(),
4486 allow_remote_extends: dispatch.cli.allow_remote_extends,
4487 dry_run: *dry_run,
4488 max_age_days: *max_age_days,
4489 output: dispatch.output,
4490 json_style: dispatch.json_style,
4491 quiet: dispatch.quiet,
4492 }),
4493 }
4494}
4495
4496fn dispatch_flags_command(dispatch: &DispatchContext<'_>, top: Option<usize>) -> ExitCode {
4497 let cli = dispatch.cli;
4498 let root = dispatch.root;
4499 let output = dispatch.output;
4500 let quiet = dispatch.quiet;
4501 let threads = dispatch.threads;
4502 let production = match resolve_production_modes(cli, root, output, false, false, false) {
4503 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::DeadCode),
4504 Err(code) => return code,
4505 };
4506 flags::run_flags(&flags::FlagsOptions {
4507 root,
4508 config_path: &cli.config,
4509 output,
4510 json_style: dispatch.json_style,
4511 no_cache: cli.no_cache,
4512 threads,
4513 quiet,
4514 allow_remote_extends: cli.allow_remote_extends,
4515 production,
4516 workspace: cli.workspace.as_deref(),
4517 changed_workspaces: cli.changed_workspaces.as_deref(),
4518 changed_since: cli.changed_since.as_deref(),
4519 explain: cli.explain,
4520 top,
4521 })
4522}
4523
4524fn dispatch_suppressions_command(
4525 dispatch: &DispatchContext<'_>,
4526 file: &[std::path::PathBuf],
4527) -> ExitCode {
4528 let cli = dispatch.cli;
4529 let root = dispatch.root;
4530 let output = dispatch.output;
4531 let production = match resolve_production_modes(cli, root, output, false, false, false) {
4532 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::DeadCode),
4533 Err(code) => return code,
4534 };
4535 suppressions::run_suppressions(&suppressions::SuppressionsOptions {
4536 root,
4537 config_path: &cli.config,
4538 output,
4539 json_style: dispatch.json_style,
4540 no_cache: cli.no_cache,
4541 threads: dispatch.threads,
4542 quiet: dispatch.quiet,
4543 allow_remote_extends: cli.allow_remote_extends,
4544 production,
4545 workspace: cli.workspace.as_deref(),
4546 changed_workspaces: cli.changed_workspaces.as_deref(),
4547 changed_since: cli.changed_since.as_deref(),
4548 file,
4549 })
4550}
4551
4552fn dispatch_guard_command(dispatch: &DispatchContext<'_>, files: &[String]) -> ExitCode {
4553 guard::run_guard(&guard::GuardOptions {
4554 root: dispatch.root,
4555 config_path: &dispatch.cli.config,
4556 output: dispatch.output,
4557 json_style: dispatch.json_style,
4558 quiet: dispatch.quiet,
4559 allow_remote_extends: dispatch.cli.allow_remote_extends,
4560 files,
4561 })
4562}
4563
4564fn dispatch_rule_pack_command(dispatch: &DispatchContext<'_>, subcommand: RulePackCli) -> ExitCode {
4565 let ctx = rule_pack::RulePackContext {
4566 root: dispatch.root,
4567 config_path: &dispatch.cli.config,
4568 output: dispatch.output,
4569 json_style: dispatch.json_style,
4570 quiet: dispatch.quiet,
4571 no_cache: dispatch.cli.no_cache,
4572 threads: Some(dispatch.threads),
4573 allow_remote_extends: dispatch.cli.allow_remote_extends,
4574 };
4575 rule_pack::run(&map_rule_pack_subcommand(subcommand), &ctx)
4576}
4577
4578fn map_rule_pack_subcommand(subcommand: RulePackCli) -> rule_pack::RulePackSubcommand {
4579 match subcommand {
4580 RulePackCli::Init {
4581 name,
4582 template,
4583 dir,
4584 no_config,
4585 } => rule_pack::RulePackSubcommand::Init(rule_pack::InitArgs {
4586 name,
4587 template,
4588 dir,
4589 no_config,
4590 }),
4591 RulePackCli::List => rule_pack::RulePackSubcommand::List,
4592 RulePackCli::Test { pack } => {
4593 rule_pack::RulePackSubcommand::Test(rule_pack::TestArgs { pack })
4594 }
4595 RulePackCli::Schema => rule_pack::RulePackSubcommand::Schema,
4596 }
4597}
4598
4599fn map_license_subcommand(sub: LicenseCli) -> license::LicenseSubcommand {
4600 match sub {
4601 LicenseCli::Activate {
4602 jwt,
4603 from_file,
4604 stdin,
4605 trial,
4606 email,
4607 } => license::LicenseSubcommand::Activate(license::ActivateArgs {
4608 raw_jwt: jwt,
4609 from_file,
4610 from_stdin: stdin,
4611 trial,
4612 email,
4613 }),
4614 LicenseCli::Status => license::LicenseSubcommand::Status,
4615 LicenseCli::Refresh => license::LicenseSubcommand::Refresh,
4616 LicenseCli::Deactivate => license::LicenseSubcommand::Deactivate,
4617 }
4618}
4619
4620fn map_telemetry_subcommand(sub: TelemetryCli) -> telemetry::TelemetryCommand {
4621 match sub {
4622 TelemetryCli::Status => telemetry::TelemetryCommand::Status,
4623 TelemetryCli::Enable => telemetry::TelemetryCommand::Enable,
4624 TelemetryCli::Disable => telemetry::TelemetryCommand::Disable,
4625 TelemetryCli::Inspect { example } => telemetry::TelemetryCommand::Inspect { example },
4626 }
4627}
4628
4629fn map_ci_subcommand(sub: CiCli) -> ci::CiCommand {
4630 match sub {
4631 command @ CiCli::PlanPrComment { .. } => map_ci_plan_pr_comment(command),
4632 command @ CiCli::PostPrComment { .. } => map_ci_post_pr_comment(command),
4633 command @ CiCli::PostReview { .. } => map_ci_post_review(command),
4634 command @ CiCli::PostCheckRun { .. } => map_ci_post_check_run(command),
4635 command @ CiCli::ReconcileReview { .. } => map_ci_reconcile_review(command),
4636 }
4637}
4638
4639fn map_ci_plan_pr_comment(command: CiCli) -> ci::CiCommand {
4640 let CiCli::PlanPrComment {
4641 body,
4642 marker_id,
4643 clean,
4644 existing_comment_id,
4645 existing_body,
4646 } = command
4647 else {
4648 unreachable!("ci plan-pr-comment mapper called with different variant");
4649 };
4650
4651 ci::CiCommand::PlanPrComment {
4652 body,
4653 marker_id,
4654 clean,
4655 existing_comment_id,
4656 existing_body,
4657 }
4658}
4659
4660fn map_ci_post_pr_comment(command: CiCli) -> ci::CiCommand {
4661 let CiCli::PostPrComment {
4662 provider,
4663 pr,
4664 mr,
4665 body,
4666 envelope,
4667 marker_id,
4668 clean,
4669 repo,
4670 project_id,
4671 api_url,
4672 dry_run,
4673 } = command
4674 else {
4675 unreachable!("ci post-pr-comment mapper called with different variant");
4676 };
4677
4678 ci::CiCommand::PostPrComment {
4679 provider: map_ci_provider(provider),
4680 target: pr.or(mr),
4681 body,
4682 envelope,
4683 marker_id,
4684 clean,
4685 repo,
4686 project_id,
4687 api_url,
4688 dry_run,
4689 }
4690}
4691
4692fn map_ci_post_review(command: CiCli) -> ci::CiCommand {
4693 let CiCli::PostReview {
4694 provider,
4695 pr,
4696 mr,
4697 envelope,
4698 repo,
4699 project_id,
4700 api_url,
4701 dry_run,
4702 } = command
4703 else {
4704 unreachable!("ci post-review mapper called with different variant");
4705 };
4706
4707 ci::CiCommand::PostReview {
4708 provider: map_ci_provider(provider),
4709 target: pr.or(mr),
4710 envelope,
4711 repo,
4712 project_id,
4713 api_url,
4714 dry_run,
4715 }
4716}
4717
4718fn map_ci_post_check_run(command: CiCli) -> ci::CiCommand {
4719 let CiCli::PostCheckRun {
4720 provider,
4721 decision,
4722 repo,
4723 head_sha,
4724 api_url,
4725 split_gates,
4726 dry_run,
4727 } = command
4728 else {
4729 unreachable!("ci post-check-run mapper called with different variant");
4730 };
4731
4732 ci::CiCommand::PostCheckRun {
4733 provider: map_ci_provider(provider),
4734 decision,
4735 repo,
4736 head_sha,
4737 api_url,
4738 split_gates,
4739 dry_run,
4740 }
4741}
4742
4743fn map_ci_reconcile_review(command: CiCli) -> ci::CiCommand {
4744 let CiCli::ReconcileReview {
4745 provider,
4746 pr,
4747 mr,
4748 envelope,
4749 repo,
4750 project_id,
4751 api_url,
4752 dry_run,
4753 } = command
4754 else {
4755 unreachable!("ci reconcile-review mapper called with different variant");
4756 };
4757
4758 ci::CiCommand::ReconcileReview {
4759 provider: map_ci_provider(provider),
4760 target: pr.or(mr),
4761 envelope,
4762 repo,
4763 project_id,
4764 api_url,
4765 dry_run,
4766 }
4767}
4768
4769fn map_ci_provider(provider: CiProviderArg) -> ci::CiProvider {
4770 match provider {
4771 CiProviderArg::Github => ci::CiProvider::Github,
4772 CiProviderArg::Gitlab => ci::CiProvider::Gitlab,
4773 }
4774}
4775
4776fn map_coverage_subcommand(sub: &CoverageCli, explain: bool) -> coverage::CoverageSubcommand {
4777 match sub {
4778 CoverageCli::Setup {
4779 yes,
4780 non_interactive,
4781 json,
4782 } => map_coverage_setup(*yes, *non_interactive, *json, explain),
4783 CoverageCli::Analyze { .. } => map_coverage_analyze(sub),
4784 CoverageCli::UploadInventory { .. } => map_coverage_upload_inventory(sub),
4785 CoverageCli::UploadSourceMaps { .. } => map_coverage_upload_source_maps(sub),
4786 CoverageCli::UploadStaticFindings { .. } => map_coverage_upload_static_findings(sub),
4787 }
4788}
4789
4790fn map_coverage_setup(
4791 yes: bool,
4792 non_interactive: bool,
4793 json: bool,
4794 explain: bool,
4795) -> coverage::CoverageSubcommand {
4796 coverage::CoverageSubcommand::Setup(coverage::SetupArgs {
4797 yes,
4798 non_interactive: non_interactive || json,
4799 json,
4800 explain,
4801 })
4802}
4803
4804fn map_coverage_analyze(sub: &CoverageCli) -> coverage::CoverageSubcommand {
4805 let CoverageCli::Analyze {
4806 runtime_coverage,
4807 cloud,
4808 api_key,
4809 api_endpoint,
4810 repo,
4811 project_id,
4812 coverage_period,
4813 environment,
4814 commit_sha,
4815 production,
4816 min_invocations_hot,
4817 min_observation_volume,
4818 low_traffic_threshold,
4819 top,
4820 blast_radius,
4821 importance,
4822 } = sub
4823 else {
4824 unreachable!("coverage analyze mapper called with non-analyze variant");
4825 };
4826 coverage::CoverageSubcommand::Analyze(coverage::AnalyzeArgs {
4827 runtime_coverage: runtime_coverage.clone(),
4828 cloud: *cloud,
4829 api_key: api_key.clone(),
4830 api_endpoint: api_endpoint.clone(),
4831 repo: repo.clone(),
4832 project_id: project_id.clone(),
4833 coverage_period: *coverage_period,
4834 environment: environment.clone(),
4835 commit_sha: commit_sha.clone(),
4836 production: *production,
4837 min_invocations_hot: *min_invocations_hot,
4838 min_observation_volume: *min_observation_volume,
4839 low_traffic_threshold: *low_traffic_threshold,
4840 top: *top,
4841 blast_radius: *blast_radius,
4842 importance: *importance,
4843 })
4844}
4845
4846fn map_coverage_upload_inventory(sub: &CoverageCli) -> coverage::CoverageSubcommand {
4847 let CoverageCli::UploadInventory {
4848 api_key,
4849 api_endpoint,
4850 project_id,
4851 git_sha,
4852 allow_dirty,
4853 exclude_paths,
4854 path_prefix,
4855 dry_run,
4856 with_callers,
4857 ignore_upload_errors,
4858 } = sub
4859 else {
4860 unreachable!("coverage inventory mapper called with non-inventory variant");
4861 };
4862 coverage::CoverageSubcommand::UploadInventory(coverage::UploadInventoryArgs {
4863 api_key: api_key.clone(),
4864 api_endpoint: api_endpoint.clone(),
4865 project_id: project_id.clone(),
4866 git_sha: git_sha.clone(),
4867 allow_dirty: *allow_dirty,
4868 exclude_paths: exclude_paths.clone(),
4869 path_prefix: path_prefix.clone(),
4870 dry_run: *dry_run,
4871 with_callers: *with_callers,
4872 ignore_upload_errors: *ignore_upload_errors,
4873 })
4874}
4875
4876fn map_coverage_upload_source_maps(sub: &CoverageCli) -> coverage::CoverageSubcommand {
4877 let CoverageCli::UploadSourceMaps {
4878 dir,
4879 include,
4880 exclude,
4881 repo,
4882 git_sha,
4883 endpoint,
4884 strip_path,
4885 dry_run,
4886 concurrency,
4887 fail_fast,
4888 } = sub
4889 else {
4890 unreachable!("coverage source-map mapper called with non-source-map variant");
4891 };
4892 coverage::CoverageSubcommand::UploadSourceMaps(coverage::UploadSourceMapsArgs {
4893 dir: dir.clone(),
4894 include: include.clone(),
4895 exclude: exclude.clone(),
4896 repo: repo.clone(),
4897 git_sha: git_sha.clone(),
4898 endpoint: endpoint.clone(),
4899 strip_path: *strip_path,
4900 dry_run: *dry_run,
4901 concurrency: *concurrency,
4902 fail_fast: *fail_fast,
4903 })
4904}
4905
4906fn map_coverage_upload_static_findings(sub: &CoverageCli) -> coverage::CoverageSubcommand {
4907 let CoverageCli::UploadStaticFindings {
4908 api_key,
4909 api_endpoint,
4910 project_id,
4911 git_sha,
4912 allow_dirty,
4913 dry_run,
4914 ignore_upload_errors,
4915 } = sub
4916 else {
4917 unreachable!("coverage static-findings mapper called with non-static variant");
4918 };
4919 coverage::CoverageSubcommand::UploadStaticFindings(coverage::UploadStaticFindingsArgs {
4920 api_key: api_key.clone(),
4921 api_endpoint: api_endpoint.clone(),
4922 project_id: project_id.clone(),
4923 git_sha: git_sha.clone(),
4924 allow_dirty: *allow_dirty,
4925 dry_run: *dry_run,
4926 ignore_upload_errors: *ignore_upload_errors,
4927 })
4928}
4929
4930struct CheckDispatchArgs {
4931 filters: IssueFilters,
4932 trace_opts: TraceOptions,
4933 include_dupes: bool,
4934 type_aware: Option<bool>,
4935 type_aware_project: Vec<std::path::PathBuf>,
4936 type_aware_require: Option<TypeAwareRequireArg>,
4937 top: Option<usize>,
4938 file: Vec<std::path::PathBuf>,
4939}
4940
4941#[derive(Clone, Copy)]
4942struct ListDispatchArgs {
4943 entry_points: bool,
4944 files: bool,
4945 plugins: bool,
4946 boundaries: bool,
4947 workspaces: bool,
4948}
4949
4950impl ListDispatchArgs {
4951 fn workspaces() -> Self {
4952 Self {
4953 entry_points: false,
4954 files: false,
4955 plugins: false,
4956 boundaries: false,
4957 workspaces: true,
4958 }
4959 }
4960}
4961
4962fn dispatch_viz(
4963 dispatch: &DispatchContext<'_>,
4964 output_path: Option<&std::path::Path>,
4965 no_open: bool,
4966 format: viz::VizFormat,
4967) -> ExitCode {
4968 let cli = dispatch.cli;
4969 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
4970 Ok(production) => production,
4971 Err(code) => return code,
4972 };
4973 viz::run_viz(&viz::VizOptions {
4974 root: dispatch.root,
4975 config_path: &cli.config,
4976 no_cache: cli.no_cache,
4977 threads: dispatch.threads,
4978 quiet: dispatch.quiet,
4979 production,
4980 allow_remote_extends: cli.allow_remote_extends,
4981 output_path,
4982 no_open,
4983 format,
4984 })
4985}
4986
4987fn dispatch_watch(dispatch: &DispatchContext<'_>, no_clear: bool) -> ExitCode {
4988 let cli = dispatch.cli;
4989 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
4990 Ok(production) => production,
4991 Err(code) => return code,
4992 };
4993 watch::run_watch(&watch::WatchOptions {
4994 root: dispatch.root,
4995 config_path: &cli.config,
4996 output: dispatch.output,
4997 json_style: dispatch.json_style,
4998 no_cache: cli.no_cache,
4999 threads: dispatch.threads,
5000 quiet: dispatch.quiet,
5001 allow_remote_extends: cli.allow_remote_extends,
5002 production,
5003 clear_screen: !no_clear,
5004 explain: cli.explain,
5005 include_entry_exports: cli.include_entry_exports,
5006 type_aware: cli.type_aware_override(),
5007 type_aware_projects: &cli.type_aware_project,
5008 type_aware_require: cli.type_aware_require.map(Into::into),
5009 })
5010}
5011
5012#[derive(Clone, Copy)]
5013struct FixDispatchArgs {
5014 dry_run: bool,
5015 yes: bool,
5016 no_create_config: bool,
5017}
5018
5019fn dispatch_fix(dispatch: &DispatchContext<'_>, args: FixDispatchArgs) -> ExitCode {
5020 let cli = dispatch.cli;
5021 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
5022 Ok(production) => production,
5023 Err(code) => return code,
5024 };
5025 fix::run_fix(&fix::FixOptions {
5026 root: dispatch.root,
5027 config_path: &cli.config,
5028 output: dispatch.output,
5029 json_style: dispatch.json_style,
5030 no_cache: cli.no_cache,
5031 threads: dispatch.threads,
5032 quiet: dispatch.quiet,
5033 emit_output: true,
5034 allow_remote_extends: cli.allow_remote_extends,
5035 dry_run: args.dry_run,
5036 yes: args.yes,
5037 production,
5038 no_create_config: args.no_create_config,
5039 type_aware: cli.type_aware_override(),
5040 type_aware_projects: &cli.type_aware_project,
5041 type_aware_require: cli.type_aware_require.map(Into::into),
5042 })
5043}
5044
5045fn dispatch_list(dispatch: &DispatchContext<'_>, args: ListDispatchArgs) -> ExitCode {
5046 let cli = dispatch.cli;
5047 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
5048 Ok(production) => production,
5049 Err(code) => return code,
5050 };
5051 list::run_list(&ListOptions {
5052 root: dispatch.root,
5053 config_path: &cli.config,
5054 output: dispatch.output,
5055 json_style: dispatch.json_style,
5056 threads: dispatch.threads,
5057 no_cache: cli.no_cache,
5058 entry_points: args.entry_points,
5059 files: args.files,
5060 plugins: args.plugins,
5061 boundaries: args.boundaries,
5062 workspaces: args.workspaces,
5063 production,
5064 allow_remote_extends: cli.allow_remote_extends,
5065 })
5066}
5067
5068fn dispatch_check(dispatch: &DispatchContext<'_>, args: &CheckDispatchArgs) -> ExitCode {
5069 let cli = dispatch.cli;
5070 let (output, quiet, fail_on_issues) =
5071 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
5072 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
5073 Ok(production) => production,
5074 Err(code) => return code,
5075 };
5076 if let Some(code) = validate_type_aware_check_options(dispatch, args) {
5077 return code;
5078 }
5079 check::run_check(&CheckOptions {
5080 root: dispatch.root,
5081 config_path: &cli.config,
5082 output,
5083 json_style: dispatch.json_style,
5084 no_cache: cli.no_cache,
5085 threads: dispatch.threads,
5086 quiet,
5087 allow_remote_extends: cli.allow_remote_extends,
5088 fail_on_issues,
5089 filters: &args.filters,
5090 changed_since: cli.changed_since.as_deref(),
5091 diff_index: None,
5092 use_shared_diff_index: true,
5093 baseline: cli.baseline.as_deref(),
5094 save_baseline: cli.save_baseline.as_deref(),
5095 sarif_file: cli.sarif_file.as_deref(),
5096 production,
5097 production_override: Some(production),
5098 workspace: cli.workspace.as_deref(),
5099 changed_workspaces: cli.changed_workspaces.as_deref(),
5100 group_by: cli.group_by,
5101 include_dupes: args.include_dupes,
5102 type_aware: args.type_aware,
5103 type_aware_config_override: None,
5104 type_aware_projects: &args.type_aware_project,
5105 type_aware_require: args.type_aware_require.map(Into::into),
5106 trace_opts: &args.trace_opts,
5107 explain: cli.explain,
5108 top: args.top,
5109 file: &args.file,
5110 include_entry_exports: cli.include_entry_exports,
5111 summary: cli.summary,
5112 regression_opts: dispatch.regression_opts(
5113 cli.changed_since.is_some()
5114 || cli.workspace.is_some()
5115 || cli.changed_workspaces.is_some()
5116 || !args.file.is_empty(),
5117 ),
5118 retain_modules_for_health: false,
5119 defer_performance: false,
5120 analysis_snapshot: fallow_config::AnalysisSnapshot::Current,
5121 })
5122}
5123
5124fn validate_type_aware_check_options(
5125 dispatch: &DispatchContext<'_>,
5126 args: &CheckDispatchArgs,
5127) -> Option<ExitCode> {
5128 let output = dispatch.output;
5129 if !args.type_aware_project.is_empty() && args.type_aware != Some(true) {
5130 return Some(emit_error(
5131 "--type-aware-project requires --type-aware",
5132 2,
5133 output,
5134 ));
5135 }
5136 if args.type_aware_require.is_some() && args.type_aware != Some(true) {
5137 return Some(emit_error(
5138 "--type-aware-require requires --type-aware",
5139 2,
5140 output,
5141 ));
5142 }
5143 if args.trace_opts.symbol_impact.is_some() && args.type_aware != Some(true) {
5144 return Some(emit_error(
5145 "--symbol-impact requires --type-aware",
5146 2,
5147 output,
5148 ));
5149 }
5150 let focused_output = args.trace_opts.trace_export.is_some()
5151 || args.trace_opts.trace_file.is_some()
5152 || args.trace_opts.trace_dependency.is_some()
5153 || args.trace_opts.impact_closure.is_some()
5154 || args.trace_opts.symbol_impact.is_some();
5155 if focused_output
5156 && !matches!(
5157 output,
5158 fallow_config::OutputFormat::Human | fallow_config::OutputFormat::Json
5159 )
5160 {
5161 return Some(emit_error(
5162 "focused trace and impact queries support human and JSON output",
5163 2,
5164 output,
5165 ));
5166 }
5167 if args.type_aware == Some(true)
5168 && !matches!(
5169 output,
5170 fallow_config::OutputFormat::Human
5171 | fallow_config::OutputFormat::Json
5172 | fallow_config::OutputFormat::Sarif
5173 | fallow_config::OutputFormat::Compact
5174 | fallow_config::OutputFormat::Markdown
5175 | fallow_config::OutputFormat::CodeClimate
5176 | fallow_config::OutputFormat::PrCommentGithub
5177 | fallow_config::OutputFormat::PrCommentGitlab
5178 | fallow_config::OutputFormat::ReviewGithub
5179 | fallow_config::OutputFormat::ReviewGitlab
5180 )
5181 {
5182 return Some(emit_error(
5183 "--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",
5184 2,
5185 output,
5186 ));
5187 }
5188 None
5189}
5190
5191fn resolve_ignore_imports(ignore_imports: bool, no_ignore_imports: bool) -> Option<bool> {
5197 if no_ignore_imports {
5198 Some(false)
5199 } else if ignore_imports {
5200 Some(true)
5201 } else {
5202 None
5203 }
5204}
5205
5206struct DupesDispatchArgs {
5207 mode: Option<DupesMode>,
5208 near: bool,
5209 min_tokens: Option<usize>,
5210 min_lines: Option<usize>,
5211 min_occurrences: Option<usize>,
5212 threshold: Option<f64>,
5213 skip_local: bool,
5214 cross_language: bool,
5215 ignore_imports: bool,
5216 no_ignore_imports: bool,
5217 top: Option<usize>,
5218 trace: Option<String>,
5219}
5220
5221fn dispatch_dupes(dispatch: &DispatchContext<'_>, args: &DupesDispatchArgs) -> ExitCode {
5222 let cli = dispatch.cli;
5223 let (output, quiet, _fail_on_issues) =
5224 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
5225 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::Dupes) {
5226 Ok(production) => production,
5227 Err(code) => return code,
5228 };
5229 dupes::run_dupes(&DupesOptions {
5230 root: dispatch.root,
5231 config_path: &cli.config,
5232 output,
5233 json_style: dispatch.json_style,
5234 no_cache: cli.no_cache,
5235 threads: dispatch.threads,
5236 quiet,
5237 allow_remote_extends: cli.allow_remote_extends,
5238 mode: args.mode,
5239 near: args.near,
5240 min_tokens: args.min_tokens,
5241 min_lines: args.min_lines,
5242 min_occurrences: args.min_occurrences,
5243 threshold: args.threshold,
5244 skip_local: args.skip_local,
5245 cross_language: args.cross_language,
5246 ignore_imports: resolve_ignore_imports(args.ignore_imports, args.no_ignore_imports),
5247 top: args.top,
5248 baseline_path: cli.baseline.as_deref(),
5249 save_baseline_path: cli.save_baseline.as_deref(),
5250 production,
5251 production_override: Some(production),
5252 trace: args.trace.as_deref(),
5253 changed_since: cli.changed_since.as_deref(),
5254 diff_index: None,
5255 use_shared_diff_index: true,
5256 changed_files: None,
5257 workspace: cli.workspace.as_deref(),
5258 changed_workspaces: cli.changed_workspaces.as_deref(),
5259 explain: cli.explain,
5260 explain_skipped: cli.explain_skipped,
5261 summary: cli.summary,
5262 group_by: cli.group_by,
5263 performance: cli.performance,
5264 })
5265}
5266
5267struct AuditDispatchArgs {
5268 production_dead_code: bool,
5269 production_health: bool,
5270 production_dupes: bool,
5271 dead_code_baseline: Option<PathBuf>,
5272 health_baseline: Option<PathBuf>,
5273 dupes_baseline: Option<PathBuf>,
5274 max_crap: Option<f64>,
5275 coverage: Option<PathBuf>,
5276 coverage_root: Option<PathBuf>,
5277 no_css: bool,
5278 css_deep: bool,
5279 no_css_deep: bool,
5280 gate: Option<AuditGateArg>,
5281 runtime_coverage: Option<PathBuf>,
5282 min_invocations_hot: u64,
5283 gate_marker: Option<String>,
5284 brief: bool,
5285 max_decisions: usize,
5286 walkthrough_guide: bool,
5288 walkthrough_file: Option<PathBuf>,
5291 walkthrough: bool,
5293 mark_viewed: Vec<PathBuf>,
5295 show_cleared: bool,
5297 show_deprioritized: bool,
5299}
5300
5301struct ResolvedAuditInputs {
5302 audit_cfg: fallow_config::AuditConfig,
5303 cache_dir: PathBuf,
5304 production: ProductionModes,
5305 dead_code_baseline: Option<PathBuf>,
5306 health_baseline: Option<PathBuf>,
5307 dupes_baseline: Option<PathBuf>,
5308 coverage: Option<PathBuf>,
5312 coverage_root: Option<PathBuf>,
5313}
5314
5315fn dispatch_audit(dispatch: &DispatchContext<'_>, args: &AuditDispatchArgs) -> ExitCode {
5316 let cli = dispatch.cli;
5317 let output = dispatch.output;
5318
5319 if cli.baseline.is_some() || cli.save_baseline.is_some() {
5320 return emit_error(
5321 "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>`)",
5322 2,
5323 output,
5324 );
5325 }
5326
5327 let inputs = match resolve_audit_inputs(dispatch, args) {
5328 Ok(inputs) => inputs,
5329 Err(code) => return code,
5330 };
5331
5332 run_resolved_audit(dispatch, args, &inputs)
5333}
5334
5335fn resolve_audit_inputs(
5336 dispatch: &DispatchContext<'_>,
5337 args: &AuditDispatchArgs,
5338) -> Result<ResolvedAuditInputs, ExitCode> {
5339 let cli = dispatch.cli;
5340 let root = dispatch.root;
5341 let output = dispatch.output;
5342 let config = load_config(
5343 root,
5344 &cli.config,
5345 LoadConfigArgs {
5346 output,
5347 no_cache: cli.no_cache,
5348 threads: dispatch.threads,
5349 production: cli.production,
5350 quiet: dispatch.quiet,
5351 allow_remote_extends: cli.allow_remote_extends,
5352 },
5353 )?;
5354 let cache_dir = config.cache_dir.clone();
5355 let audit_cfg = config.audit;
5356 let production = resolve_production_modes(
5357 cli,
5358 root,
5359 output,
5360 args.production_dead_code,
5361 args.production_health,
5362 args.production_dupes,
5363 )?;
5364 let resolved_dead_code_baseline = resolve_audit_baseline_path(
5365 root,
5366 args.dead_code_baseline.as_deref(),
5367 audit_cfg.dead_code_baseline.as_deref(),
5368 );
5369 let resolved_health_baseline = resolve_audit_baseline_path(
5370 root,
5371 args.health_baseline.as_deref(),
5372 audit_cfg.health_baseline.as_deref(),
5373 );
5374 let resolved_dupes_baseline = resolve_audit_baseline_path(
5375 root,
5376 args.dupes_baseline.as_deref(),
5377 audit_cfg.dupes_baseline.as_deref(),
5378 );
5379 let coverage_inputs = resolve_coverage_inputs(
5380 args.coverage.as_deref(),
5381 args.coverage_root.as_deref(),
5382 output,
5383 || Ok(config.health),
5384 )?;
5385
5386 Ok(ResolvedAuditInputs {
5387 audit_cfg,
5388 cache_dir,
5389 production,
5390 dead_code_baseline: resolved_dead_code_baseline,
5391 health_baseline: resolved_health_baseline,
5392 dupes_baseline: resolved_dupes_baseline,
5393 coverage: coverage_inputs.coverage,
5394 coverage_root: coverage_inputs.coverage_root,
5395 })
5396}
5397
5398fn audit_css_enabled(config: &fallow_config::AuditConfig, args: &AuditDispatchArgs) -> bool {
5399 !args.no_css && config.css.unwrap_or(true)
5400}
5401
5402fn audit_css_deep_enabled(config: &fallow_config::AuditConfig, args: &AuditDispatchArgs) -> bool {
5403 audit_css_enabled(config, args)
5404 && !args.no_css_deep
5405 && (args.css_deep || config.css_deep.unwrap_or(true))
5406}
5407
5408fn run_resolved_audit(
5409 dispatch: &DispatchContext<'_>,
5410 args: &AuditDispatchArgs,
5411 inputs: &ResolvedAuditInputs,
5412) -> ExitCode {
5413 let cli = dispatch.cli;
5414 audit::run_audit_with_type_aware(
5415 &audit::AuditOptions {
5416 root: dispatch.root,
5417 config_path: &cli.config,
5418 cache_dir: &inputs.cache_dir,
5419 output: dispatch.output,
5420 json_style: dispatch.json_style,
5421 no_cache: cli.no_cache,
5422 threads: dispatch.threads,
5423 quiet: dispatch.quiet,
5424 allow_remote_extends: cli.allow_remote_extends,
5425 changed_since: cli.changed_since.as_deref(),
5426 production: cli.production,
5427 production_dead_code: Some(inputs.production.dead_code),
5428 production_health: Some(inputs.production.health),
5429 production_dupes: Some(inputs.production.dupes),
5430 workspace: cli.workspace.as_deref(),
5431 changed_workspaces: cli.changed_workspaces.as_deref(),
5432 explain: cli.explain,
5433 explain_skipped: cli.explain_skipped,
5434 performance: cli.performance,
5435 group_by: cli.group_by,
5436 dead_code_baseline: inputs.dead_code_baseline.as_deref(),
5437 health_baseline: inputs.health_baseline.as_deref(),
5438 dupes_baseline: inputs.dupes_baseline.as_deref(),
5439 health_baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
5440 max_crap: args.max_crap,
5441 coverage: inputs.coverage.as_deref(),
5442 coverage_root: inputs.coverage_root.as_deref(),
5443 gate: args.gate.map_or(inputs.audit_cfg.gate, Into::into),
5444 include_entry_exports: cli.include_entry_exports,
5445 css: audit_css_enabled(&inputs.audit_cfg, args),
5449 css_deep: audit_css_deep_enabled(&inputs.audit_cfg, args),
5450 runtime_coverage: args.runtime_coverage.as_deref(),
5451 min_invocations_hot: args.min_invocations_hot,
5452 brief: args.brief,
5453 max_decisions: args.max_decisions,
5454 walkthrough_guide: args.walkthrough_guide,
5455 walkthrough: args.walkthrough,
5456 mark_viewed: &args.mark_viewed,
5457 show_cleared: args.show_cleared,
5458 walkthrough_file: args.walkthrough_file.as_deref(),
5459 show_deprioritized: args.show_deprioritized,
5460 },
5461 args.gate_marker.as_deref(),
5462 audit::AuditTypeAwareOptions {
5463 enabled: cli.type_aware_override(),
5464 config_default: inputs.audit_cfg.type_aware,
5465 projects: &cli.type_aware_project,
5466 require: cli.type_aware_require.map(Into::into),
5467 },
5468 )
5469}
5470
5471fn dispatch_decision_surface(dispatch: &DispatchContext<'_>, max_decisions: usize) -> ExitCode {
5475 let args = decision_surface_audit_args(max_decisions);
5476 let inputs = match resolve_audit_inputs(dispatch, &args) {
5477 Ok(inputs) => inputs,
5478 Err(code) => return code,
5479 };
5480 audit::run_decision_surface(&decision_surface_audit_options(
5481 dispatch,
5482 &inputs,
5483 max_decisions,
5484 ))
5485}
5486
5487fn decision_surface_audit_args(max_decisions: usize) -> AuditDispatchArgs {
5488 AuditDispatchArgs {
5489 production_dead_code: false,
5490 production_health: false,
5491 production_dupes: false,
5492 dead_code_baseline: None,
5493 health_baseline: None,
5494 dupes_baseline: None,
5495 max_crap: None,
5496 coverage: None,
5497 coverage_root: None,
5498 no_css: true,
5499 css_deep: false,
5500 no_css_deep: false,
5501 gate: None,
5502 runtime_coverage: None,
5503 min_invocations_hot: 0,
5504 gate_marker: None,
5505 brief: true,
5506 max_decisions,
5507 walkthrough_guide: false,
5508 walkthrough_file: None,
5509 walkthrough: false,
5510 mark_viewed: Vec::new(),
5511 show_cleared: false,
5512 show_deprioritized: false,
5513 }
5514}
5515
5516fn decision_surface_audit_options<'a>(
5517 dispatch: &'a DispatchContext<'a>,
5518 inputs: &'a ResolvedAuditInputs,
5519 max_decisions: usize,
5520) -> audit::AuditOptions<'a> {
5521 let cli = dispatch.cli;
5522 audit::AuditOptions {
5523 root: dispatch.root,
5524 config_path: &cli.config,
5525 cache_dir: &inputs.cache_dir,
5526 output: dispatch.output,
5527 json_style: dispatch.json_style,
5528 no_cache: cli.no_cache,
5529 threads: dispatch.threads,
5530 quiet: dispatch.quiet,
5531 allow_remote_extends: cli.allow_remote_extends,
5532 changed_since: cli.changed_since.as_deref(),
5533 production: cli.production,
5534 production_dead_code: Some(inputs.production.dead_code),
5535 production_health: Some(inputs.production.health),
5536 production_dupes: Some(inputs.production.dupes),
5537 workspace: cli.workspace.as_deref(),
5538 changed_workspaces: cli.changed_workspaces.as_deref(),
5539 explain: cli.explain,
5540 explain_skipped: cli.explain_skipped,
5541 performance: cli.performance,
5542 group_by: cli.group_by,
5543 dead_code_baseline: inputs.dead_code_baseline.as_deref(),
5544 health_baseline: inputs.health_baseline.as_deref(),
5545 dupes_baseline: inputs.dupes_baseline.as_deref(),
5546 health_baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
5547 max_crap: None,
5548 coverage: None,
5549 coverage_root: None,
5550 gate: inputs.audit_cfg.gate,
5551 include_entry_exports: cli.include_entry_exports,
5552 css: false,
5554 css_deep: false,
5555 runtime_coverage: None,
5556 min_invocations_hot: 0,
5557 brief: true,
5558 max_decisions,
5559 walkthrough_guide: false,
5560 walkthrough: false,
5561 mark_viewed: &[],
5562 show_cleared: false,
5563 walkthrough_file: None,
5564 show_deprioritized: false,
5565 }
5566}
5567
5568struct HealthDispatchArgs<'a> {
5569 max_cyclomatic: Option<u16>,
5570 max_cognitive: Option<u16>,
5571 max_crap: Option<f64>,
5572 top: Option<usize>,
5573 sort: health::SortBy,
5574 complexity: bool,
5575 complexity_breakdown: bool,
5576 file_scores: bool,
5577 coverage_gaps: bool,
5578 hotspots: bool,
5579 ownership: bool,
5580 ownership_emails: Option<fallow_config::EmailMode>,
5581 targets: bool,
5582 type_coupling: bool,
5583 css: bool,
5584 effort: Option<EffortFilter>,
5585 score: bool,
5586 min_score: Option<f64>,
5587 min_severity: Option<fallow_output::FindingSeverity>,
5588 report_only: bool,
5589 since: Option<&'a str>,
5590 min_commits: Option<u32>,
5591 save_snapshot: Option<&'a Option<String>>,
5592 trend: bool,
5593 coverage: Option<&'a std::path::Path>,
5594 coverage_root: Option<&'a std::path::Path>,
5595 runtime_coverage: Option<&'a std::path::Path>,
5596 min_invocations_hot: u64,
5597 min_observation_volume: Option<u32>,
5598 low_traffic_threshold: Option<f64>,
5599}
5600
5601type ResolvedHealthCoverageInputs = fallow_api::CoverageInputs;
5602
5603fn resolve_coverage_inputs(
5614 cli_coverage: Option<&std::path::Path>,
5615 cli_coverage_root: Option<&std::path::Path>,
5616 output: fallow_config::OutputFormat,
5617 config_health: impl FnOnce() -> Result<fallow_config::HealthConfig, ExitCode>,
5618) -> Result<ResolvedHealthCoverageInputs, ExitCode> {
5619 let explicit = fallow_api::CoverageInputs {
5620 coverage: cli_coverage.map(std::path::Path::to_path_buf),
5621 coverage_root: cli_coverage_root.map(std::path::Path::to_path_buf),
5622 };
5623 let env = fallow_api::CoverageInputs {
5624 coverage: path_from_env("FALLOW_COVERAGE"),
5625 coverage_root: path_from_env("FALLOW_COVERAGE_ROOT"),
5626 };
5627 let config_health = if fallow_api::CoverageInputs::needs_config_layer(&explicit, &env) {
5628 Some(config_health()?)
5629 } else {
5630 None
5631 };
5632
5633 fallow_api::resolve_coverage_inputs(explicit, env, config_health.as_ref())
5634 .map_err(|err| emit_error(&err.to_string(), 2, output))
5635}
5636
5637fn resolve_health_coverage_inputs(
5640 dispatch: &DispatchContext<'_>,
5641 cli_coverage: Option<&std::path::Path>,
5642 cli_coverage_root: Option<&std::path::Path>,
5643) -> Result<ResolvedHealthCoverageInputs, ExitCode> {
5644 resolve_coverage_inputs(cli_coverage, cli_coverage_root, dispatch.output, || {
5645 Ok(load_config(
5646 dispatch.root,
5647 &dispatch.cli.config,
5648 LoadConfigArgs {
5649 output: dispatch.output,
5650 no_cache: dispatch.cli.no_cache,
5651 threads: dispatch.threads,
5652 production: dispatch.cli.production,
5653 quiet: dispatch.quiet,
5654 allow_remote_extends: dispatch.cli.allow_remote_extends,
5655 },
5656 )?
5657 .health)
5658 })
5659}
5660
5661fn path_from_env(name: &str) -> Option<PathBuf> {
5662 std::env::var_os(name)
5663 .filter(|value| !value.is_empty())
5664 .map(PathBuf::from)
5665}
5666
5667fn validate_health_report_only_gate(
5668 report_only: bool,
5669 min_score: Option<f64>,
5670 min_severity: Option<fallow_output::FindingSeverity>,
5671 output: fallow_config::OutputFormat,
5672) -> Result<(), ExitCode> {
5673 if report_only && (min_score.is_some() || min_severity.is_some()) {
5674 return Err(emit_error(
5675 "--report-only cannot be combined with --min-score or --min-severity. \
5676 --report-only always exits 0; drop it to gate on score/severity, or \
5677 drop the gate flags to stay advisory.",
5678 2,
5679 output,
5680 ));
5681 }
5682
5683 Ok(())
5684}
5685
5686fn resolve_runtime_coverage_options(
5687 runtime_coverage: Option<&std::path::Path>,
5688 min_invocations_hot: u64,
5689 min_observation_volume: Option<u32>,
5690 low_traffic_threshold: Option<f64>,
5691 output: fallow_config::OutputFormat,
5692) -> Result<Option<fallow_engine::health::RuntimeCoverageOptions>, ExitCode> {
5693 let Some(path) = runtime_coverage else {
5694 return Ok(None);
5695 };
5696
5697 health::coverage::prepare_options(
5698 path,
5699 min_invocations_hot,
5700 min_observation_volume,
5701 low_traffic_threshold,
5702 output,
5703 )
5704 .map(Some)
5705}
5706
5707fn dispatch_health(dispatch: &DispatchContext<'_>, args: &HealthDispatchArgs<'_>) -> ExitCode {
5708 let cli = dispatch.cli;
5709 let root = dispatch.root;
5710 let (output, _quiet, _fail_on_issues) =
5711 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
5712 if let Err(code) = validate_health_report_only_gate(
5713 args.report_only,
5714 args.min_score,
5715 args.min_severity,
5716 output,
5717 ) {
5718 return code;
5719 }
5720 let runtime_coverage = match resolve_runtime_coverage_options(
5721 args.runtime_coverage,
5722 args.min_invocations_hot,
5723 args.min_observation_volume,
5724 args.low_traffic_threshold,
5725 output,
5726 ) {
5727 Ok(options) => options,
5728 Err(code) => return code,
5729 };
5730 let production = match resolve_production_modes(cli, root, output, false, false, false) {
5731 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::Health),
5732 Err(code) => return code,
5733 };
5734 let coverage_inputs =
5735 match resolve_health_coverage_inputs(dispatch, args.coverage, args.coverage_root) {
5736 Ok(inputs) => inputs,
5737 Err(code) => return code,
5738 };
5739 let run = derive_health_dispatch_run(args, output, &coverage_inputs, runtime_coverage);
5740 run_health_dispatch(dispatch, args, ResolvedHealthDispatch { run, production })
5741}
5742
5743fn derive_health_dispatch_run<'a>(
5744 args: &'a HealthDispatchArgs<'a>,
5745 output: fallow_config::OutputFormat,
5746 coverage_inputs: &'a ResolvedHealthCoverageInputs,
5747 runtime_coverage: Option<fallow_engine::health::RuntimeCoverageOptions>,
5748) -> fallow_engine::health::HealthRunOptions<'a> {
5749 let mut run = fallow_engine::health::derive_health_run_options(
5750 fallow_engine::health::HealthRunOptionsInput {
5751 output,
5752 thresholds: health_threshold_overrides(args),
5753 top: args.top,
5754 sort: args.sort.clone().into(),
5755 complexity: args.complexity,
5756 file_scores: args.file_scores,
5757 coverage_gaps: args.coverage_gaps,
5758 hotspots: args.hotspots,
5759 ownership: args.ownership,
5760 ownership_emails: args.ownership_emails,
5761 targets: args.targets,
5762 css: args.css,
5763 effort: args.effort.map(EffortFilter::to_estimate),
5764 score: args.score,
5765 gates: health_gate_options(args),
5766 snapshot_requested: args.save_snapshot.is_some(),
5767 trend: args.trend,
5768 since: args.since,
5769 min_commits: args.min_commits,
5770 coverage_inputs: health_coverage_inputs(coverage_inputs),
5771 runtime_coverage,
5772 },
5773 );
5774 if args.type_coupling && !run.sections.any_section {
5775 run.sections = fallow_engine::health::DerivedHealthSections {
5776 any_section: true,
5777 complexity: false,
5778 file_scores: false,
5779 coverage_gaps: false,
5780 hotspots: false,
5781 targets: false,
5782 css: false,
5783 score: false,
5784 force_full: false,
5785 score_only_output: false,
5786 };
5787 }
5788 run
5789}
5790
5791fn health_threshold_overrides(
5792 args: &HealthDispatchArgs<'_>,
5793) -> fallow_engine::health::HealthThresholdOverrides {
5794 fallow_engine::health::HealthThresholdOverrides {
5795 max_cyclomatic: args.max_cyclomatic,
5796 max_cognitive: args.max_cognitive,
5797 max_crap: args.max_crap,
5798 }
5799}
5800
5801fn health_gate_options(args: &HealthDispatchArgs<'_>) -> fallow_engine::health::HealthGateOptions {
5802 fallow_engine::health::HealthGateOptions {
5803 min_score: args.min_score,
5804 min_severity: args.min_severity,
5805 report_only: args.report_only,
5806 }
5807}
5808
5809fn health_coverage_inputs(
5810 coverage_inputs: &ResolvedHealthCoverageInputs,
5811) -> fallow_engine::health::HealthCoverageInputs<'_> {
5812 fallow_engine::health::HealthCoverageInputs {
5813 coverage: coverage_inputs.coverage.as_deref(),
5814 coverage_root: coverage_inputs.coverage_root.as_deref(),
5815 coverage_relocated: false,
5816 }
5817}
5818
5819struct ResolvedHealthDispatch<'a> {
5823 run: fallow_engine::health::HealthRunOptions<'a>,
5824 production: bool,
5825}
5826
5827fn run_health_dispatch(
5830 dispatch: &DispatchContext<'_>,
5831 args: &HealthDispatchArgs<'_>,
5832 resolved: ResolvedHealthDispatch<'_>,
5833) -> ExitCode {
5834 let cli = dispatch.cli;
5835 let (output, quiet, _fail_on_issues) =
5836 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
5837 let run = resolved.run;
5838 let sections = run.sections;
5839 let production = resolved.production;
5840 health::run_health(
5841 &HealthOptions {
5842 root: dispatch.root,
5843 config_path: &cli.config,
5844 output,
5845 no_cache: cli.no_cache,
5846 threads: dispatch.threads,
5847 quiet,
5848 thresholds: run.thresholds,
5849 top: run.top,
5850 sort: run.sort,
5851 production,
5852 production_override: Some(production),
5853 allow_remote_extends: cli.allow_remote_extends,
5854 changed_since: cli.changed_since.as_deref(),
5855 diff_index: None,
5856 use_shared_diff_index: true,
5857 workspace: cli.workspace.as_deref(),
5858 changed_workspaces: cli.changed_workspaces.as_deref(),
5859 baseline: cli.baseline.as_deref(),
5860 save_baseline: cli.save_baseline.as_deref(),
5861 baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
5862 baseline_mode_explicit: cli.baseline_mode.is_some(),
5863 complexity: sections.complexity,
5864 file_scores: sections.file_scores,
5865 coverage_gaps: sections.coverage_gaps,
5866 config_activates_coverage_gaps: !sections.any_section,
5867 hotspots: sections.hotspots,
5868 ownership: run.ownership,
5869 ownership_emails: run.ownership_emails,
5870 targets: sections.targets,
5871 css: sections.css,
5872 css_deep: false,
5873 force_full: sections.force_full,
5874 score_only_output: sections.score_only_output,
5875 enforce_coverage_gap_gate: true,
5876 effort: run.effort,
5877 score: sections.score,
5878 gates: run.gates,
5879 since: run.since,
5880 min_commits: run.min_commits,
5881 explain: cli.explain,
5882 summary: cli.summary,
5883 save_snapshot: args
5884 .save_snapshot
5885 .map(|opt| PathBuf::from(opt.as_deref().unwrap_or_default())),
5886 trend: args.trend,
5887 coverage_inputs: run.coverage_inputs,
5888 performance: cli.performance,
5889 runtime_coverage: run.runtime_coverage,
5890 churn_file: cli.churn_file.as_deref(),
5891 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
5892 complexity_breakdown: args.complexity_breakdown,
5893 group_by: cli.group_by.map(Into::into),
5894 },
5895 dispatch.json_style,
5896 &health::TypeAwareHealthOptions {
5897 enabled: cli.type_aware_override(),
5898 requested: args.type_coupling,
5899 unfiltered: health_type_coupling_is_default_section(args),
5900 projects: &cli.type_aware_project,
5901 require: cli.type_aware_require.map(Into::into),
5902 },
5903 )
5904}
5905
5906fn health_type_coupling_is_default_section(args: &HealthDispatchArgs<'_>) -> bool {
5907 !args.complexity
5908 && !args.file_scores
5909 && !args.coverage_gaps
5910 && !args.hotspots
5911 && !args.ownership
5912 && !args.targets
5913 && !args.css
5914 && !args.score
5915 && args.min_score.is_none()
5916 && args.min_severity.is_none()
5917 && args.runtime_coverage.is_none()
5918}
5919
5920#[cfg(test)]
5921mod tests {
5922 use super::*;
5923
5924 #[test]
5928 fn cli_definition_has_no_flag_collisions() {
5929 use clap::CommandFactory;
5930 Cli::command().debug_assert();
5931 }
5932
5933 #[test]
5934 fn impact_statusline_subcommand_parses() {
5935 use clap::Parser;
5936
5937 let cli = Cli::try_parse_from(["fallow", "impact", "statusline"]).expect("argv parses");
5938 assert!(matches!(
5939 cli.command,
5940 Some(Command::Impact {
5941 subcommand: Some(ImpactCli::Statusline),
5942 ..
5943 })
5944 ));
5945 }
5946
5947 #[test]
5948 fn impact_statusline_bypasses_command_epilogue() {
5949 use clap::Parser;
5950
5951 let statusline =
5952 Cli::try_parse_from(["fallow", "impact", "statusline"]).expect("argv parses");
5953 assert!(is_impact_statusline(&statusline));
5954
5955 let status = Cli::try_parse_from(["fallow", "impact", "status"]).expect("argv parses");
5956 assert!(!is_impact_statusline(&status));
5957
5958 let all_statusline =
5959 Cli::try_parse_from(["fallow", "impact", "--all", "statusline"]).expect("argv parses");
5960 assert!(!is_impact_statusline(&all_statusline));
5961 }
5962
5963 #[test]
5964 fn regression_baseline_help_explains_the_default_destination() {
5965 use clap::CommandFactory;
5966 let help = Cli::command().render_long_help().to_string();
5967
5968 assert!(help.contains("Omit PATH to update regression.baseline"));
5969 assert!(help.contains("discovered fallow config"));
5970 assert!(help.contains("create .fallowrc.json when none exists"));
5971 }
5972
5973 #[test]
5977 fn after_help_lists_every_task_matrix_command() {
5978 for row in crate::task_matrix::TASK_MATRIX {
5979 assert!(
5980 TOP_LEVEL_AFTER_LONG_HELP.contains(row.command),
5981 "root --help cheat sheet is missing task-matrix command '{}'; \
5982 update the top_level_task_cheat_sheet! fragment to match TASK_MATRIX",
5983 row.command
5984 );
5985 }
5986 }
5987
5988 #[test]
5995 fn after_help_lists_every_visible_subcommand() {
5996 use clap::CommandFactory;
5997
5998 for sub in Cli::command().get_subcommands() {
5999 if sub.is_hide_set() {
6000 continue;
6001 }
6002 let name = sub.get_name();
6003 let listed = TOP_LEVEL_AFTER_LONG_HELP
6004 .lines()
6005 .any(|line| line.split_whitespace().next() == Some(name));
6006 assert!(
6007 listed,
6008 "root --help command list is missing subcommand '{name}'; \
6009 add it to a top_level_*_command_groups! section"
6010 );
6011 }
6012 }
6013
6014 #[test]
6018 fn short_help_stays_scannable_with_cheat_sheet_and_pointer() {
6019 use clap::CommandFactory;
6020
6021 let help = Cli::command().render_help().to_string();
6022 let lines = help.lines().count();
6023 assert!(
6024 lines < 90,
6025 "root -h grew to {lines} lines; keep the short surface under 90 \
6026 (curate hide_short_help and the short after-help instead)"
6027 );
6028 assert!(help.contains("When the agent is about to..."));
6029 assert!(help.contains("Run fallow --help for the complete command list."));
6030 }
6031
6032 #[test]
6036 fn high_value_commands_route_to_distinct_workflows() {
6037 use clap::Parser;
6038 use fallow_config::OutputFormat;
6039
6040 let distinct = [
6041 (vec!["fallow", "impact"], telemetry::Workflow::Impact),
6042 (vec!["fallow", "security"], telemetry::Workflow::Security),
6043 (vec!["fallow", "fix"], telemetry::Workflow::Fix),
6044 (
6045 vec!["fallow", "explain", "unused-exports"],
6046 telemetry::Workflow::Explain,
6047 ),
6048 (
6049 vec!["fallow", "watch"],
6050 telemetry::Workflow::CodeQualityReview,
6051 ),
6052 (
6053 vec!["fallow", "list"],
6054 telemetry::Workflow::ProjectInventory,
6055 ),
6056 (
6057 vec!["fallow", "workspaces"],
6058 telemetry::Workflow::ProjectInventory,
6059 ),
6060 (
6061 vec!["fallow", "schema"],
6062 telemetry::Workflow::ProjectInventory,
6063 ),
6064 (vec!["fallow", "init"], telemetry::Workflow::Setup),
6065 (
6066 vec!["fallow", "hooks", "install", "--target", "git"],
6067 telemetry::Workflow::Setup,
6068 ),
6069 (vec!["fallow", "config-schema"], telemetry::Workflow::Setup),
6070 (vec!["fallow", "plugin-schema"], telemetry::Workflow::Setup),
6071 (
6072 vec!["fallow", "rule-pack-schema"],
6073 telemetry::Workflow::Setup,
6074 ),
6075 (vec!["fallow", "config"], telemetry::Workflow::Setup),
6076 (
6077 vec!["fallow", "ci-template", "gitlab"],
6078 telemetry::Workflow::Setup,
6079 ),
6080 (vec!["fallow", "migrate"], telemetry::Workflow::Setup),
6081 (
6082 vec!["fallow", "telemetry", "status"],
6083 telemetry::Workflow::Setup,
6084 ),
6085 (vec!["fallow", "setup-hooks"], telemetry::Workflow::Setup),
6086 (
6087 vec!["fallow", "audit-cache", "remove", "--root", "."],
6088 telemetry::Workflow::Setup,
6089 ),
6090 (
6091 vec!["fallow", "license", "status"],
6092 telemetry::Workflow::License,
6093 ),
6094 ];
6095 for (argv, expected) in distinct {
6096 let cli = Cli::try_parse_from(&argv).expect("argv parses");
6097 assert_eq!(
6098 telemetry_workflow_for_command(cli.command.as_ref(), OutputFormat::Json),
6099 expected,
6100 "{argv:?} should map to {expected:?}"
6101 );
6102 }
6103 }
6104
6105 #[test]
6110 fn version_flag_accepts_lower_v_upper_v_and_long() {
6111 use clap::CommandFactory;
6112 for argv in [["fallow", "-v"], ["fallow", "-V"], ["fallow", "--version"]] {
6113 let err = Cli::command()
6114 .try_get_matches_from(argv)
6115 .expect_err("version flag should short-circuit parsing");
6116 assert_eq!(
6117 err.kind(),
6118 clap::error::ErrorKind::DisplayVersion,
6119 "{argv:?} should trigger the Version action"
6120 );
6121 }
6122 }
6123
6124 #[test]
6129 fn cli_help_text_contains_no_implementation_status_wording() {
6130 use clap::CommandFactory;
6131 let mut root = Cli::command();
6132 let mut violations: Vec<(String, String)> = Vec::new();
6133 visit_help(&mut root, "fallow", &mut violations);
6134 assert!(
6135 violations.is_empty(),
6136 "found implementation-status wording in --help output:\n{}",
6137 violations
6138 .iter()
6139 .map(|(cmd, line)| format!(" {cmd}: {line}"))
6140 .collect::<Vec<_>>()
6141 .join("\n")
6142 );
6143 }
6144
6145 #[test]
6146 fn dependency_override_help_is_package_manager_neutral() {
6147 use clap::CommandFactory;
6148 let help = Cli::command()
6149 .find_subcommand_mut("dead-code")
6150 .expect("dead-code command")
6151 .render_long_help()
6152 .to_string();
6153
6154 assert!(help.contains("Only report unused package-manager dependency overrides"));
6155 assert!(help.contains("Only report misconfigured package-manager dependency overrides"));
6156 assert!(!help.contains("unused pnpm dependency overrides"));
6157 assert!(!help.contains("misconfigured pnpm dependency overrides"));
6158 }
6159
6160 #[test]
6161 fn top_level_help_groups_commands_by_workflow() {
6162 use clap::CommandFactory;
6163 let help = Cli::command().render_long_help().to_string();
6164 let expected_order = [
6165 "Analysis:",
6166 " dead-code",
6167 " dupes",
6168 " health",
6169 " flags",
6170 " security",
6171 " audit",
6172 "Workflow:",
6173 " watch",
6174 " fix",
6175 "Project inspection:",
6176 " list",
6177 " workspaces",
6178 " explain",
6179 " impact",
6180 " viz",
6181 "Setup and configuration:",
6182 " init",
6183 " recommend",
6184 " migrate",
6185 " config",
6186 " config-schema",
6187 " plugin-schema",
6188 " plugin-check",
6189 " rule-pack-schema",
6190 "Automation and CI:",
6191 " ci",
6192 " ci-template",
6193 " hooks",
6194 " setup-hooks",
6195 "Runtime coverage:",
6196 " coverage",
6197 " license",
6198 "Reference:",
6199 " schema",
6200 " help",
6201 "Options:",
6202 ];
6203 let mut cursor = 0;
6204 for needle in expected_order {
6205 let Some(offset) = help[cursor..].find(needle) else {
6206 panic!("top-level help missing `{needle}` after byte {cursor}:\n{help}");
6207 };
6208 cursor += offset + needle.len();
6209 }
6210 }
6211
6212 #[test]
6213 fn security_help_hides_globals_rejected_by_security_validator() {
6214 let help = render_security_help(SecurityHelpTarget::Parent);
6215
6216 for long in SECURITY_UNSUPPORTED_GLOBAL_LONGS {
6217 assert!(
6218 !help_contains_long_flag(&help, long),
6219 "security help must hide unsupported --{long}:\n{help}"
6220 );
6221 }
6222
6223 for long in [
6224 "root",
6225 "config",
6226 "format",
6227 "quiet",
6228 "no-cache",
6229 "threads",
6230 "changed-since",
6231 "diff-file",
6232 "diff-stdin",
6233 "workspace",
6234 "changed-workspaces",
6235 "ci",
6236 "fail-on-issues",
6237 "sarif-file",
6238 "summary",
6239 "output-file",
6240 "max-file-size",
6241 "explain",
6242 "surface",
6243 ] {
6244 assert!(
6245 help_contains_long_flag(&help, long),
6246 "security help must keep supported --{long}:\n{help}"
6247 );
6248 }
6249 }
6250
6251 #[test]
6252 fn security_help_detection_covers_subcommand_and_help_alias_forms() {
6253 assert_eq!(
6254 security_help_target(["security", "--help"]),
6255 Some(SecurityHelpTarget::Parent)
6256 );
6257 assert_eq!(
6258 security_help_target(["security", "-h"]),
6259 Some(SecurityHelpTarget::Parent)
6260 );
6261 assert_eq!(
6262 security_help_target(["--format", "json", "security", "--help"]),
6263 Some(SecurityHelpTarget::Parent)
6264 );
6265 assert_eq!(
6266 security_help_target(["help", "security"]),
6267 Some(SecurityHelpTarget::Parent)
6268 );
6269 assert_eq!(
6270 security_help_target(["security", "survivors", "--help"]),
6271 Some(SecurityHelpTarget::Survivors)
6272 );
6273 assert_eq!(
6274 security_help_target(["security", "survivors", "-h"]),
6275 Some(SecurityHelpTarget::Survivors)
6276 );
6277 assert_eq!(
6278 security_help_target(["help", "security", "survivors"]),
6279 Some(SecurityHelpTarget::Survivors)
6280 );
6281 assert_eq!(
6282 security_help_target(["security", "blind-spots", "--help"]),
6283 Some(SecurityHelpTarget::BlindSpots)
6284 );
6285 assert_eq!(
6286 security_help_target(["help", "security", "blind-spots"]),
6287 Some(SecurityHelpTarget::BlindSpots)
6288 );
6289 assert_eq!(security_help_target(["health", "--help"]), None);
6290 assert_eq!(security_help_target(["help", "health"]), None);
6291 }
6292
6293 #[test]
6294 fn security_unsupported_global_validator_matches_hidden_help_contract() {
6295 for (argv, expected) in [
6296 (vec!["fallow", "security", "--performance"], "--performance"),
6297 (
6298 vec!["fallow", "security", "--baseline", "base.json"],
6299 "--baseline",
6300 ),
6301 (
6302 vec!["fallow", "security", "--dupes-mode", "weak"],
6303 "--dupes-mode",
6304 ),
6305 ] {
6306 let cli = Cli::try_parse_from(argv).expect("security global parses before validation");
6307 assert_eq!(unsupported_security_global(&cli), Some(expected));
6308 }
6309
6310 let explain = Cli::try_parse_from(["fallow", "security", "--explain"])
6311 .expect("security --explain parses");
6312 assert_eq!(unsupported_security_global(&explain), None);
6313 }
6314
6315 #[test]
6316 fn programmatic_common_options_track_analysis_affecting_cli_globals() {
6317 use clap::CommandFactory;
6318
6319 let cli_flags: std::collections::BTreeSet<String> = Cli::command()
6320 .get_arguments()
6321 .filter(|arg| arg.is_global_set())
6322 .filter_map(|arg| arg.get_long().map(str::to_owned))
6323 .filter(|name| {
6324 matches!(
6325 name.as_str(),
6326 "root"
6327 | "config"
6328 | "allow-remote-extends"
6329 | "no-cache"
6330 | "threads"
6331 | "changed-since"
6332 | "diff-file"
6333 | "production"
6334 | "workspace"
6335 | "changed-workspaces"
6336 | "explain"
6337 )
6338 })
6339 .collect();
6340 let programmatic_flags: std::collections::BTreeSet<String> =
6341 fallow_api::COMMON_ANALYSIS_OPTION_FLAGS
6342 .iter()
6343 .map(|flag| (*flag).to_owned())
6344 .collect();
6345
6346 assert_eq!(programmatic_flags, cli_flags);
6347 }
6348
6349 #[test]
6350 fn dead_code_registry_filter_flags_are_exposed_by_clap() {
6351 use clap::CommandFactory;
6352
6353 let cli = Cli::command();
6354 let dead_code = cli
6355 .get_subcommands()
6356 .find(|command| command.get_name() == "dead-code")
6357 .expect("dead-code subcommand is registered");
6358 let cli_flags: std::collections::BTreeSet<String> = dead_code
6359 .get_arguments()
6360 .filter_map(|arg| arg.get_long().map(|long| format!("--{long}")))
6361 .collect();
6362
6363 for flag in fallow_types::issue_meta::DEAD_CODE_FILTER_FLAGS.iter() {
6364 assert!(
6365 cli_flags.contains(*flag),
6366 "registry filter flag {flag} is missing from dead-code clap args"
6367 );
6368 }
6369 }
6370
6371 fn help_contains_long_flag(help: &str, long: &str) -> bool {
6372 let flag = format!("--{long}");
6373 help.split(|c: char| c.is_whitespace() || c == ',' || c == '[' || c == ']')
6374 .any(|token| token == flag)
6375 }
6376
6377 fn visit_help(cmd: &mut clap::Command, path: &str, violations: &mut Vec<(String, String)>) {
6378 let help = cmd.render_long_help().to_string();
6379 for line in scan_forbidden(&help) {
6380 violations.push((path.to_owned(), line));
6381 }
6382 let names: Vec<String> = cmd
6383 .get_subcommands()
6384 .map(|sub| sub.get_name().to_owned())
6385 .collect();
6386 for name in names {
6387 if name == "help" {
6388 continue;
6389 }
6390 if let Some(sub) = cmd.find_subcommand_mut(&name) {
6391 let sub_path = format!("{path} {name}");
6392 visit_help(sub, &sub_path, violations);
6393 }
6394 }
6395 }
6396
6397 fn scan_forbidden(s: &str) -> Vec<String> {
6398 let lower = s.to_ascii_lowercase();
6399 let mut out = Vec::new();
6400 for word in ["stub", "placeholder"] {
6401 if let Some(idx) = find_whole_word(&lower, word) {
6402 out.push(extract_line(s, idx));
6403 }
6404 }
6405 if let Some(idx) = lower.find("not yet") {
6406 out.push(extract_line(s, idx));
6407 }
6408 out
6409 }
6410
6411 fn find_whole_word(haystack: &str, word: &str) -> Option<usize> {
6412 let bytes = haystack.as_bytes();
6413 let mut start = 0;
6414 while let Some(rel) = haystack[start..].find(word) {
6415 let abs = start + rel;
6416 let before_ok = abs == 0 || !bytes[abs - 1].is_ascii_alphanumeric();
6417 let after_idx = abs + word.len();
6418 let after_ok = after_idx >= bytes.len() || !bytes[after_idx].is_ascii_alphanumeric();
6419 if before_ok && after_ok {
6420 return Some(abs);
6421 }
6422 start = abs + word.len();
6423 }
6424 None
6425 }
6426
6427 fn extract_line(s: &str, byte_idx: usize) -> String {
6428 let line_start = s[..byte_idx].rfind('\n').map_or(0, |i| i + 1);
6429 let line_end = s[byte_idx..].find('\n').map_or(s.len(), |i| byte_idx + i);
6430 s[line_start..line_end].trim().to_owned()
6431 }
6432
6433 #[test]
6434 fn emit_error_returns_given_exit_code() {
6435 let code = emit_error("test error", 2, fallow_config::OutputFormat::Human);
6436 assert_eq!(code, ExitCode::from(2));
6437 }
6438
6439 fn telemetry_run_for_mode(mode: telemetry::AnalysisMode) -> TelemetryRun {
6440 TelemetryRun {
6441 workflow: telemetry::Workflow::Health,
6442 output: fallow_config::OutputFormat::Json,
6443 quiet: true,
6444 start: std::time::Instant::now(),
6445 context: telemetry::WorkflowContext {
6446 run_scope: telemetry::RunScope::FullProject,
6447 config_shape: telemetry::ConfigShape::Default,
6448 output_destination: telemetry::OutputDestination::Stdout,
6449 analysis_mode: mode,
6450 },
6451 }
6452 }
6453
6454 #[test]
6455 fn fallback_failure_reason_skips_success_and_findings() {
6456 let run = telemetry_run_for_mode(telemetry::AnalysisMode::Static);
6457
6458 assert_eq!(fallback_failure_reason_for(&run, ExitCode::SUCCESS), None);
6459 assert_eq!(fallback_failure_reason_for(&run, ExitCode::from(1)), None);
6460 }
6461
6462 #[test]
6463 fn fallback_failure_reason_classifies_network_auth_and_analysis() {
6464 let static_run = telemetry_run_for_mode(telemetry::AnalysisMode::Static);
6465 let cloud_run = telemetry_run_for_mode(telemetry::AnalysisMode::ProductionCoverage);
6466
6467 assert_eq!(
6468 fallback_failure_reason_for(&static_run, ExitCode::from(api::NETWORK_EXIT_CODE)),
6469 Some(telemetry::FailureReason::Network),
6470 );
6471 assert_eq!(
6472 fallback_failure_reason_for(&static_run, ExitCode::from(12)),
6473 Some(telemetry::FailureReason::Auth),
6474 );
6475 assert_eq!(
6476 fallback_failure_reason_for(&cloud_run, ExitCode::from(3)),
6477 Some(telemetry::FailureReason::Auth),
6478 );
6479 assert_eq!(
6480 fallback_failure_reason_for(&static_run, ExitCode::from(2)),
6481 Some(telemetry::FailureReason::Analysis),
6482 );
6483 }
6484
6485 #[test]
6486 fn bare_coverage_flags_parse_without_subcommand() {
6487 let cli = Cli::try_parse_from([
6488 "fallow",
6489 "--coverage",
6490 "coverage/coverage-final.json",
6491 "--coverage-root",
6492 "/ci/workspace",
6493 ])
6494 .expect("bare combined coverage flags should parse");
6495 assert!(cli.command.is_none());
6496 assert_eq!(
6497 cli.coverage.as_deref(),
6498 Some(std::path::Path::new("coverage/coverage-final.json"))
6499 );
6500 assert_eq!(
6501 cli.coverage_root.as_deref(),
6502 Some(std::path::Path::new("/ci/workspace"))
6503 );
6504 }
6505
6506 #[test]
6507 fn bare_coverage_before_subcommand_is_detectable() {
6508 let cli = Cli::try_parse_from([
6509 "fallow",
6510 "--coverage",
6511 "coverage/coverage-final.json",
6512 "dead-code",
6513 ])
6514 .expect("clap should parse pre-subcommand bare coverage for custom rejection");
6515 assert!(cli.command.is_some());
6516 assert!(cli_has_bare_coverage_input(&cli));
6517 let message = bare_coverage_subcommand_error_message();
6518 assert!(message.contains("bare combined-mode flags"));
6519 assert!(message.contains("fallow health --coverage <coverage-final.json>"));
6520 }
6521
6522 #[test]
6523 fn subcommand_coverage_flag_keeps_regular_clap_error() {
6524 let Err(err) = Cli::try_parse_from(["fallow", "dead-code", "--coverage"]) else {
6525 panic!("dead-code --coverage should fail to parse");
6526 };
6527 assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
6528 }
6529
6530 #[test]
6531 fn type_aware_flags_parse_for_semantic_analysis() {
6532 let cli = Cli::try_parse_from([
6533 "fallow",
6534 "dead-code",
6535 "--unused-class-members",
6536 "--type-aware",
6537 "--type-aware-project",
6538 "tsconfig.json",
6539 "--type-aware-project",
6540 "packages/web/tsconfig.json",
6541 ])
6542 .expect("type-aware flag should parse");
6543 assert!(cli.type_aware);
6544 assert_eq!(
6545 cli.type_aware_project,
6546 [
6547 PathBuf::from("tsconfig.json"),
6548 PathBuf::from("packages/web/tsconfig.json")
6549 ]
6550 );
6551 let Some(Command::Check {
6552 unused_class_members,
6553 ..
6554 }) = cli.command
6555 else {
6556 panic!("dead-code should parse as the check command");
6557 };
6558 assert!(unused_class_members);
6559 }
6560
6561 #[test]
6562 fn no_type_aware_conflicts_with_type_aware() {
6563 let Err(err) = Cli::try_parse_from(["fallow", "audit", "--type-aware", "--no-type-aware"])
6564 else {
6565 panic!("--no-type-aware must conflict with --type-aware");
6566 };
6567 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
6568 }
6569
6570 #[test]
6571 fn no_type_aware_forces_semantic_analysis_off() {
6572 let cli = Cli::try_parse_from(["fallow", "audit", "--no-type-aware"])
6573 .expect("--no-type-aware should parse on audit");
6574 assert_eq!(cli.type_aware_override(), Some(false));
6575
6576 let cli = Cli::try_parse_from(["fallow", "dead-code", "--type-aware"])
6577 .expect("--type-aware should parse");
6578 assert_eq!(cli.type_aware_override(), Some(true));
6579
6580 let cli = Cli::try_parse_from(["fallow", "dead-code"]).expect("bare command should parse");
6581 assert_eq!(cli.type_aware_override(), None);
6582 }
6583
6584 #[test]
6585 fn type_aware_status_output_hides_host_paths() {
6586 let root = Path::new("/private/work/project");
6587 let output = type_aware_status_output(
6588 root,
6589 fallow_api::TypeAwareStatus {
6590 available: false,
6591 discovery_source: Some("environment-override"),
6592 companion_path: Some(PathBuf::from("/private/tools/fallow-type-aware")),
6593 package_version: None,
6594 protocol_version: 7,
6595 backend_family: None,
6596 backend_version: None,
6597 remediation: Some(
6598 "failed to launch /private/tools/fallow-type-aware from /private/work/project"
6599 .to_string(),
6600 ),
6601 },
6602 );
6603
6604 assert_eq!(
6605 output.schema_version.0,
6606 fallow_output::TYPE_AWARE_STATUS_SCHEMA_VERSION
6607 );
6608 assert_eq!(output.companion_path.as_deref(), Some("fallow-type-aware"));
6609 let remediation = output.remediation.expect("remediation");
6610 assert!(!remediation.contains("/private/"));
6611 assert!(remediation.contains("fallow-type-aware"));
6612 }
6613
6614 #[test]
6615 fn format_parsing_covers_all_variants() {
6616 assert!(matches!(parse_format_arg("json"), Some(Format::Json)));
6617 assert!(matches!(parse_format_arg("JSON"), Some(Format::Json)));
6618 assert!(matches!(parse_format_arg("human"), Some(Format::Human)));
6619 assert!(matches!(parse_format_arg("sarif"), Some(Format::Sarif)));
6620 assert!(matches!(parse_format_arg("compact"), Some(Format::Compact)));
6621 assert!(matches!(
6622 parse_format_arg("markdown"),
6623 Some(Format::Markdown)
6624 ));
6625 assert!(matches!(parse_format_arg("md"), Some(Format::Markdown)));
6626 assert!(matches!(
6627 parse_format_arg("codeclimate"),
6628 Some(Format::CodeClimate)
6629 ));
6630 assert!(matches!(
6631 parse_format_arg("gitlab-codequality"),
6632 Some(Format::CodeClimate)
6633 ));
6634 assert!(matches!(
6635 parse_format_arg("gitlab-code-quality"),
6636 Some(Format::CodeClimate)
6637 ));
6638 assert!(matches!(
6639 parse_format_arg("pr-comment-github"),
6640 Some(Format::PrCommentGithub)
6641 ));
6642 assert!(matches!(
6643 parse_format_arg("pr-comment-gitlab"),
6644 Some(Format::PrCommentGitlab)
6645 ));
6646 assert!(matches!(
6647 parse_format_arg("review-github"),
6648 Some(Format::ReviewGithub)
6649 ));
6650 assert!(matches!(
6651 parse_format_arg("review-gitlab"),
6652 Some(Format::ReviewGitlab)
6653 ));
6654 assert!(matches!(parse_format_arg("badge"), Some(Format::Badge)));
6655 assert!(parse_format_arg("xml").is_none());
6656 assert!(parse_format_arg("").is_none());
6657 }
6658
6659 #[test]
6660 fn quiet_parsing_logic() {
6661 let parse = |s: &str| -> bool { s == "1" || s.eq_ignore_ascii_case("true") };
6662 assert!(parse("1"));
6663 assert!(parse("true"));
6664 assert!(parse("TRUE"));
6665 assert!(parse("True"));
6666 assert!(!parse("0"));
6667 assert!(!parse("false"));
6668 assert!(!parse("yes"));
6669 }
6670
6671 #[test]
6672 fn tracing_filter_defaults_to_warn_without_env() {
6673 assert_eq!(build_tracing_filter(None).to_string(), "warn");
6674 }
6675
6676 #[test]
6677 fn tracing_filter_respects_explicit_env_directives() {
6678 assert_eq!(build_tracing_filter(Some("info")).to_string(), "info");
6679 }
6680
6681 #[test]
6682 fn tracing_filter_treats_empty_env_as_off() {
6683 assert_eq!(build_tracing_filter(Some("")).to_string(), "off");
6684 assert_eq!(build_tracing_filter(Some(" ")).to_string(), "off");
6685 }
6686}