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_decision_surface;
27mod audit_focus;
28mod audit_walkthrough;
29mod base_worktree;
30pub use base_worktree::canonical_root_hash;
34mod walkthrough_state;
35use fallow_engine::baseline;
36mod cache_notice;
37mod check;
38mod ci;
39mod ci_template;
40mod cli_format;
41mod cli_hooks;
42mod cli_impact;
43mod cli_production;
44mod cli_report;
45mod cli_startup;
46pub use fallow_engine::codeowners;
47mod combined;
48mod config;
49mod coverage;
50mod dupes;
51pub mod explain;
52mod fix;
53mod flags;
54mod guard;
55mod health;
56mod impact;
57mod init;
58mod inspect;
59mod json_style;
60mod license;
61mod list;
62mod migrate;
63mod onboarding;
64#[cfg(test)]
65mod output_envelope;
66mod output_runtime;
67mod path_util;
68mod plugin_check;
69mod rayon_pool;
70mod regression;
71pub mod report;
72mod rule_pack;
73mod runtime_support;
74mod schema;
75mod security;
76mod security_help;
77mod setup_hooks;
78mod signal;
79mod suppressions;
80mod task_matrix;
81mod telemetry;
82mod trace_chain;
83mod update_check;
84use fallow_engine::validate;
85use fallow_engine::vital_signs;
86mod cli_telemetry;
87mod viz;
88mod watch;
89
90use check::{CheckOptions, IssueFilters, TraceOptions};
91pub(crate) mod error;
93#[cfg(test)]
94use cli_format::parse_format_arg;
95use cli_format::{Format, FormatConfig};
96use cli_hooks::{HooksCli, run_hooks_command};
97use cli_impact::{ImpactCli, ImpactCrossRepoOpts, ImpactSortCli, dispatch_impact};
98use cli_production::{ProductionModes, resolve_production_modes};
99#[cfg(test)]
100use cli_startup::build_tracing_filter;
101use cli_startup::{
102 bare_coverage_subcommand_error_message, cli_has_bare_coverage_input, parse_cli_args,
103 run_pre_dispatch_checks, setup_tracing, validate_inputs,
104};
105#[cfg(test)]
106use cli_telemetry::TelemetryRun;
107#[cfg(test)]
108use cli_telemetry::{fallback_failure_reason_for, telemetry_workflow_for_command};
109use cli_telemetry::{record_run_epilogue, start_telemetry_run};
110use dupes::{DupesMode, DupesOptions};
111use error::emit_error;
112use health::{HealthOptions, SortBy};
113use list::ListOptions;
114pub(crate) use runtime_support::{AnalysisKind, GroupBy};
115pub(crate) use runtime_support::{
116 ConfigLoadOptions, LoadConfigArgs, build_ownership_resolver, load_config,
117 load_config_for_analysis,
118};
119#[cfg(test)]
120use security_help::{SECURITY_UNSUPPORTED_GLOBAL_LONGS, SecurityHelpTarget};
121use security_help::{render_security_help, security_help_target};
122
123const DEFAULT_MIN_INVOCATIONS_HOT: u64 = 100;
124
125const TOP_LEVEL_HELP_TEMPLATE: &str =
126 "{about-with-newline}\n{usage-heading} {usage}{after-help}\n\nOptions:\n{options}";
127
128const TOP_LEVEL_AFTER_HELP: &str = "\
129Analysis:
130 dead-code Analyze unused code, dependency hygiene, and architecture cycles
131 dupes Find copy-paste and structural code duplication
132 health Analyze complexity, maintainability, hotspots, and coverage gaps
133 flags Detect feature flag usage patterns
134 security Surface local security candidates for agent verification (opt-in)
135 audit Review changed files for dead code, complexity, duplication, and styling
136
137Workflow:
138 watch Re-run analysis as files change
139 fix Auto-fix safe unused-code findings
140
141Project inspection:
142 list List discovered files, entry points, plugins, boundaries, and workspaces
143 inspect Inspect one file or exported symbol as a bundled evidence query
144 workspaces Show monorepo workspace discovery diagnostics
145 explain Explain one issue type without running analysis
146 suppressions List active fallow-ignore suppression markers
147 impact Show what fallow has done for you (opt-in, local-only)
148 viz Generate an interactive HTML map of the codebase
149
150Setup and configuration:
151 init Create a fallow config, optionally with a Git hook
152 audit-cache Maintain reusable audit base-snapshot caches
153 recommend Recommend a project-tailored config for an agent to author
154 migrate Migrate knip, jscpd, or stylelint config to fallow
155 config Show the resolved config and loaded config file
156 config-schema Print the fallow config JSON Schema
157 plugin-schema Print the external plugin JSON Schema
158 plugin-check Dry-run external plugins and report what they seed
159 rule-pack-schema Print the rule pack JSON Schema
160
161Automation and CI:
162 ci Build PR/MR feedback envelopes
163 ci-template Print or vendor CI integration templates
164 report Re-render a saved --format json results file (GitHub formats)
165 hooks Install or remove fallow-managed Git and agent hooks
166 setup-hooks Legacy agent-hook installer
167
168Runtime coverage:
169 coverage Set up or analyze runtime coverage data
170 license Manage the paid-feature license
171 telemetry Manage opt-in product telemetry
172
173Reference:
174 schema Dump the CLI interface as machine-readable JSON
175 help Print this message or the help of a command
176
177When no command is given, fallow runs dead-code + dupes + health together.
178Use --only/--skip to select specific analyses.
179
180When the agent is about to...
181 delete an \"unused\" export or file fallow dead-code --trace <file>:<export>
182 delete an \"unused\" dependency fallow dead-code --trace-dependency <name>
183 commit or open a PR fallow audit --base <ref>
184 prioritize refactoring fallow health --hotspots --targets
185 ask who owns code fallow health --ownership
186 check untested-but-reachable code fallow health --coverage-gaps
187 consolidate duplication fallow dupes --trace dup:<fingerprint>
188 find feature flags fallow flags
189 check architecture rules before editing fallow guard <files>
190 surface security candidates fallow security
191 inspect a target before editing fallow inspect --file <path>
192 understand a finding fallow explain <issue-type>
193 scope a monorepo --workspace <glob> / --changed-workspaces <ref>";
194
195#[derive(Parser)]
196#[command(
197 name = "fallow",
198 about = "Codebase analyzer for TypeScript/JavaScript: unused code, circular dependencies, code duplication, complexity hotspots, and architecture boundary violations",
199 version,
200 disable_version_flag = true,
201 help_template = TOP_LEVEL_HELP_TEMPLATE,
202 after_help = TOP_LEVEL_AFTER_HELP
203)]
204struct Cli {
205 #[command(subcommand)]
206 command: Option<Command>,
207
208 #[arg(
212 short = 'v',
213 visible_short_alias = 'V',
214 long = "version",
215 action = clap::ArgAction::Version
216 )]
217 version: Option<bool>,
218
219 #[arg(short, long, global = true)]
221 root: Option<PathBuf>,
222
223 #[arg(short, long, global = true)]
225 config: Option<PathBuf>,
226
227 #[arg(long, global = true)]
229 allow_remote_extends: bool,
230
231 #[arg(
233 short,
234 long,
235 visible_alias = "output",
236 global = true,
237 default_value = "human"
238 )]
239 format: Format,
240
241 #[arg(long, global = true)]
243 pretty: bool,
244
245 #[arg(short, long, global = true)]
247 quiet: bool,
248
249 #[arg(long, global = true)]
251 no_cache: bool,
252
253 #[arg(long, global = true)]
255 threads: Option<usize>,
256
257 #[arg(long, visible_alias = "base", global = true)]
259 changed_since: Option<String>,
260
261 #[arg(long = "diff-file", value_name = "PATH", global = true)]
266 diff_file: Option<PathBuf>,
267
268 #[arg(long = "diff-stdin", global = true)]
271 diff_stdin: bool,
272
273 #[arg(long = "churn-file", value_name = "PATH", global = true)]
280 churn_file: Option<PathBuf>,
281
282 #[arg(long = "max-file-size", value_name = "MB", global = true)]
289 max_file_size: Option<u32>,
290
291 #[arg(long, global = true)]
293 baseline: Option<PathBuf>,
294
295 #[arg(long, global = true, value_name = "RUN_ID", hide = true)]
301 parent_run: Option<String>,
302
303 #[arg(long, global = true)]
305 save_baseline: Option<PathBuf>,
306
307 #[arg(long, global = true)]
310 production: bool,
311
312 #[arg(long = "no-production", global = true, conflicts_with = "production")]
316 no_production: bool,
317
318 #[arg(long = "production-dead-code")]
320 production_dead_code: bool,
321
322 #[arg(long = "production-health")]
324 production_health: bool,
325
326 #[arg(long = "production-dupes")]
328 production_dupes: bool,
329
330 #[arg(short, long, global = true, value_delimiter = ',')]
334 workspace: Option<Vec<String>>,
335
336 #[arg(long, global = true, value_name = "REF")]
339 changed_workspaces: Option<String>,
340
341 #[arg(long, global = true)]
343 group_by: Option<GroupBy>,
344
345 #[arg(long, global = true)]
347 performance: bool,
348
349 #[arg(long, global = true)]
351 explain: bool,
352
353 #[arg(long, global = true)]
355 explain_skipped: bool,
356
357 #[arg(long, global = true)]
359 summary: bool,
360
361 #[arg(long, global = true)]
363 ci: bool,
364
365 #[arg(long, global = true)]
367 fail_on_issues: bool,
368
369 #[arg(long, global = true, value_name = "PATH")]
371 sarif_file: Option<PathBuf>,
372
373 #[arg(short = 'o', long, global = true, value_name = "PATH")]
377 output_file: Option<PathBuf>,
378
379 #[arg(
388 long = "report-path-prefix",
389 visible_alias = "annotations-path-prefix",
390 global = true,
391 value_name = "PREFIX"
392 )]
393 report_path_prefix: Option<String>,
394
395 #[arg(long, global = true)]
397 fail_on_regression: bool,
398
399 #[arg(long, global = true, value_name = "TOLERANCE", default_value = "0")]
401 tolerance: String,
402
403 #[arg(long, global = true, value_name = "PATH")]
405 regression_baseline: Option<PathBuf>,
406
407 #[expect(
411 clippy::option_option,
412 reason = "clap pattern: None=not passed, Some(None)=flag only (write to config), Some(Some(path))=write to file"
413 )]
414 #[arg(long, global = true, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
415 save_regression_baseline: Option<Option<String>>,
416
417 #[arg(long, value_delimiter = ',')]
419 only: Vec<AnalysisKind>,
420
421 #[arg(long, value_delimiter = ',')]
423 skip: Vec<AnalysisKind>,
424
425 #[arg(long = "dupes-mode", global = true)]
427 dupes_mode: Option<DupesMode>,
428
429 #[arg(long = "dupes-threshold", global = true)]
431 dupes_threshold: Option<f64>,
432
433 #[arg(long = "dupes-min-tokens", global = true)]
435 dupes_min_tokens: Option<usize>,
436
437 #[arg(long = "dupes-min-lines", global = true)]
439 dupes_min_lines: Option<usize>,
440
441 #[arg(long = "dupes-min-occurrences", global = true, value_parser = parse_min_occurrences)]
443 dupes_min_occurrences: Option<usize>,
444
445 #[arg(long = "dupes-skip-local", global = true)]
447 dupes_skip_local: bool,
448
449 #[arg(long = "dupes-cross-language", global = true)]
451 dupes_cross_language: bool,
452
453 #[arg(long = "dupes-ignore-imports", global = true)]
456 dupes_ignore_imports: bool,
457
458 #[arg(
461 long = "dupes-no-ignore-imports",
462 global = true,
463 conflicts_with = "dupes_ignore_imports"
464 )]
465 dupes_no_ignore_imports: bool,
466
467 #[arg(long)]
469 score: bool,
470
471 #[arg(long)]
473 trend: bool,
474
475 #[expect(
478 clippy::option_option,
479 reason = "clap pattern: None=not passed, Some(None)=default path, Some(Some(path))=custom path"
480 )]
481 #[arg(long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
482 save_snapshot: Option<Option<String>>,
483
484 #[arg(long, value_name = "PATH")]
487 coverage: Option<PathBuf>,
488
489 #[arg(long = "coverage-root", value_name = "PATH")]
492 coverage_root: Option<PathBuf>,
493
494 #[arg(long, global = true)]
496 include_entry_exports: bool,
497}
498
499#[derive(Subcommand)]
500enum Command {
501 #[command(name = "dead-code", alias = "check")]
503 Check {
504 #[arg(long)]
506 unused_files: bool,
507
508 #[arg(long)]
510 unused_exports: bool,
511
512 #[arg(long)]
514 unused_deps: bool,
515
516 #[arg(long)]
518 unused_types: bool,
519
520 #[arg(long)]
522 private_type_leaks: bool,
523
524 #[arg(long)]
526 unused_enum_members: bool,
527
528 #[arg(long)]
530 unused_class_members: bool,
531
532 #[arg(long)]
534 unused_store_members: bool,
535
536 #[arg(long)]
538 unprovided_injects: bool,
539
540 #[arg(long)]
542 unrendered_components: bool,
543
544 #[arg(long)]
546 unused_component_props: bool,
547
548 #[arg(long)]
550 unused_component_emits: bool,
551
552 #[arg(long)]
554 unused_component_inputs: bool,
555
556 #[arg(long)]
558 unused_component_outputs: bool,
559
560 #[arg(long)]
562 unused_svelte_events: bool,
563
564 #[arg(long)]
566 unused_server_actions: bool,
567
568 #[arg(long)]
570 unused_load_data_keys: bool,
571
572 #[arg(long)]
574 unresolved_imports: bool,
575
576 #[arg(long)]
578 unlisted_deps: bool,
579
580 #[arg(long)]
582 duplicate_exports: bool,
583
584 #[arg(long)]
586 circular_deps: bool,
587
588 #[arg(long)]
590 re_export_cycles: bool,
591
592 #[arg(long)]
594 boundary_violations: bool,
595
596 #[arg(long)]
598 policy_violations: bool,
599
600 #[arg(long)]
602 stale_suppressions: bool,
603
604 #[arg(long)]
606 unused_catalog_entries: bool,
607
608 #[arg(long)]
610 empty_catalog_groups: bool,
611
612 #[arg(long)]
614 unresolved_catalog_references: bool,
615
616 #[arg(long)]
618 unused_dependency_overrides: bool,
619
620 #[arg(long)]
622 misconfigured_dependency_overrides: bool,
623
624 #[arg(long)]
626 include_dupes: bool,
627
628 #[arg(long, value_name = "FILE:EXPORT")]
630 trace: Option<String>,
631
632 #[arg(long, value_name = "PATH")]
634 trace_file: Option<String>,
635
636 #[arg(long, value_name = "PACKAGE")]
638 trace_dependency: Option<String>,
639
640 #[arg(long, value_name = "PATH")]
644 impact_closure: Option<String>,
645
646 #[arg(long)]
648 top: Option<usize>,
649
650 #[arg(long, value_name = "PATH")]
654 file: Vec<std::path::PathBuf>,
655 },
656
657 Watch {
659 #[arg(long)]
661 no_clear: bool,
662 },
663
664 Inspect {
666 #[arg(
668 long,
669 value_name = "PATH",
670 conflicts_with = "symbol",
671 required_unless_present = "symbol"
672 )]
673 file: Option<String>,
674
675 #[arg(long, value_name = "FILE:EXPORT", conflicts_with = "file")]
677 symbol: Option<String>,
678
679 #[arg(long)]
684 symbol_chain: bool,
685
686 #[arg(long)]
689 churn: bool,
690 },
691
692 Trace {
701 #[arg(value_name = "FILE:SYMBOL")]
703 symbol: String,
704
705 #[arg(long)]
708 callers: bool,
709
710 #[arg(long)]
713 callees: bool,
714
715 #[arg(long, value_name = "N")]
718 depth: Option<u32>,
719 },
720
721 Fix {
736 #[arg(long)]
738 dry_run: bool,
739
740 #[arg(long, alias = "force")]
742 yes: bool,
743
744 #[arg(long)]
751 no_create_config: bool,
752 },
753
754 Init {
763 #[arg(long)]
765 toml: bool,
766
767 #[arg(long, conflicts_with_all = ["toml", "hooks", "branch"])]
769 agents: bool,
770
771 #[arg(long)]
775 hooks: bool,
776
777 #[arg(long, requires = "hooks")]
779 branch: Option<String>,
780
781 #[arg(long, conflicts_with_all = ["toml", "agents", "hooks", "branch"])]
785 decline: bool,
786 },
787
788 Hooks {
795 #[command(subcommand)]
796 subcommand: HooksCli,
797 },
798
799 Ci {
801 #[command(subcommand)]
802 subcommand: CiCli,
803 },
804
805 ConfigSchema,
807
808 PluginSchema,
810
811 PluginCheck,
813
814 RulePackSchema,
816
817 RulePack {
819 #[command(subcommand)]
820 subcommand: RulePackCli,
821 },
822
823 Guard {
825 #[arg(required = true, num_args = 1..)]
827 files: Vec<String>,
828 },
829
830 Config {
848 #[arg(long)]
850 path: bool,
851 },
852
853 Recommend,
861
862 List {
864 #[arg(long)]
866 entry_points: bool,
867
868 #[arg(long)]
870 files: bool,
871
872 #[arg(long)]
874 plugins: bool,
875
876 #[arg(long)]
878 boundaries: bool,
879
880 #[arg(long)]
884 workspaces: bool,
885 },
886
887 Workspaces,
893
894 Dupes {
896 #[arg(long)]
899 mode: Option<DupesMode>,
900
901 #[arg(long)]
904 min_tokens: Option<usize>,
905
906 #[arg(long)]
909 min_lines: Option<usize>,
910
911 #[arg(long, value_parser = parse_min_occurrences)]
916 min_occurrences: Option<usize>,
917
918 #[arg(long)]
921 threshold: Option<f64>,
922
923 #[arg(long)]
925 skip_local: bool,
926
927 #[arg(long)]
929 cross_language: bool,
930
931 #[arg(long)]
935 ignore_imports: bool,
936
937 #[arg(long, conflicts_with = "ignore_imports")]
940 no_ignore_imports: bool,
941
942 #[arg(long)]
945 top: Option<usize>,
946
947 #[arg(long, value_name = "FILE:LINE")]
949 trace: Option<String>,
950 },
951
952 Health {
958 #[arg(long)]
960 max_cyclomatic: Option<u16>,
961
962 #[arg(long)]
964 max_cognitive: Option<u16>,
965
966 #[arg(long)]
970 max_crap: Option<f64>,
971
972 #[arg(long)]
974 top: Option<usize>,
975
976 #[arg(long, default_value = "cyclomatic")]
978 sort: SortBy,
979
980 #[arg(long)]
983 complexity: bool,
984
985 #[arg(long)]
992 complexity_breakdown: bool,
993
994 #[arg(long)]
999 file_scores: bool,
1000
1001 #[arg(long)]
1004 coverage_gaps: bool,
1005
1006 #[arg(long)]
1009 hotspots: bool,
1010
1011 #[arg(long)]
1015 ownership: bool,
1016
1017 #[arg(long, value_name = "MODE", value_enum)]
1022 ownership_emails: Option<EmailModeArg>,
1023
1024 #[arg(long)]
1027 targets: bool,
1028
1029 #[arg(long)]
1034 css: bool,
1035
1036 #[arg(long, value_enum)]
1039 effort: Option<EffortFilter>,
1040
1041 #[arg(long)]
1044 score: bool,
1045
1046 #[arg(long, value_name = "N")]
1055 min_score: Option<f64>,
1056
1057 #[arg(long, value_name = "LEVEL", value_enum)]
1061 min_severity: Option<HealthSeverityCli>,
1062
1063 #[arg(long)]
1067 report_only: bool,
1068
1069 #[arg(long, value_name = "DURATION")]
1072 since: Option<String>,
1073
1074 #[arg(long, value_name = "N")]
1076 min_commits: Option<u32>,
1077
1078 #[expect(
1082 clippy::option_option,
1083 reason = "clap pattern: None=not passed, Some(None)=flag only, Some(Some(path))=with value"
1084 )]
1085 #[arg(long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
1086 save_snapshot: Option<Option<String>>,
1087
1088 #[arg(long)]
1092 trend: bool,
1093
1094 #[arg(long, value_name = "PATH")]
1103 coverage: Option<PathBuf>,
1104
1105 #[arg(long, value_name = "PATH")]
1111 coverage_root: Option<PathBuf>,
1112
1113 #[arg(long, value_name = "PATH")]
1117 runtime_coverage: Option<PathBuf>,
1118
1119 #[arg(long, default_value_t = 100)]
1121 min_invocations_hot: u64,
1122
1123 #[arg(long, value_name = "N")]
1129 min_observation_volume: Option<u32>,
1130
1131 #[arg(long, value_name = "RATIO")]
1136 low_traffic_threshold: Option<f64>,
1137 },
1138
1139 Flags {
1146 #[arg(long)]
1148 top: Option<usize>,
1149 },
1150
1151 Suppressions {
1161 #[arg(long, value_name = "PATH")]
1163 file: Vec<std::path::PathBuf>,
1164 },
1165
1166 Explain {
1172 #[arg(required = true, num_args = 1.., value_name = "ISSUE_TYPE")]
1174 issue_type: Vec<String>,
1175 },
1176
1177 #[command(visible_alias = "review")]
1202 Audit {
1203 #[arg(long = "production-dead-code")]
1205 production_dead_code: bool,
1206
1207 #[arg(long = "production-health")]
1209 production_health: bool,
1210
1211 #[arg(long = "production-dupes")]
1213 production_dupes: bool,
1214
1215 #[arg(long)]
1218 dead_code_baseline: Option<PathBuf>,
1219
1220 #[arg(long)]
1223 health_baseline: Option<PathBuf>,
1224
1225 #[arg(long)]
1228 dupes_baseline: Option<PathBuf>,
1229
1230 #[arg(long)]
1234 max_crap: Option<f64>,
1235
1236 #[arg(long, value_name = "PATH")]
1240 coverage: Option<PathBuf>,
1241
1242 #[arg(long, value_name = "PATH")]
1245 coverage_root: Option<PathBuf>,
1246
1247 #[arg(long = "no-css")]
1249 no_css: bool,
1250
1251 #[arg(long)]
1255 css_deep: bool,
1256
1257 #[arg(long = "no-css-deep")]
1259 no_css_deep: bool,
1260
1261 #[arg(long, value_enum)]
1267 gate: Option<AuditGateArg>,
1268
1269 #[arg(long, value_name = "PATH")]
1278 runtime_coverage: Option<PathBuf>,
1279
1280 #[arg(long, default_value_t = 100)]
1283 min_invocations_hot: u64,
1284
1285 #[arg(long, value_name = "MARKER", hide = true)]
1290 gate_marker: Option<String>,
1291
1292 #[arg(long)]
1298 brief: bool,
1299
1300 #[arg(
1305 long,
1306 value_name = "N",
1307 default_value_t = audit_decision_surface::DEFAULT_DECISION_CAP
1308 )]
1309 max_decisions: usize,
1310
1311 #[arg(long, conflicts_with_all = ["walkthrough_file", "walkthrough"])]
1319 walkthrough_guide: bool,
1320
1321 #[arg(long, value_name = "PATH")]
1329 walkthrough_file: Option<PathBuf>,
1330
1331 #[arg(long, conflicts_with_all = ["walkthrough_guide", "walkthrough_file"])]
1337 walkthrough: bool,
1338
1339 #[arg(long, value_name = "PATH")]
1345 mark_viewed: Vec<PathBuf>,
1346
1347 #[arg(long)]
1351 show_cleared: bool,
1352
1353 #[arg(long)]
1359 show_deprioritized: bool,
1360 },
1361
1362 AuditCache {
1364 #[command(subcommand)]
1365 subcommand: AuditCacheCli,
1366 },
1367
1368 DecisionSurface {
1380 #[arg(
1383 long,
1384 value_name = "N",
1385 default_value_t = audit_decision_surface::DEFAULT_DECISION_CAP
1386 )]
1387 max_decisions: usize,
1388 },
1389
1390 Impact {
1400 #[command(subcommand)]
1401 subcommand: Option<ImpactCli>,
1402 #[arg(long)]
1406 all: bool,
1407 #[arg(long, value_enum, default_value_t = ImpactSortCli::Recent)]
1409 sort: ImpactSortCli,
1410 #[arg(long)]
1413 limit: Option<usize>,
1414 },
1415
1416 Security {
1447 #[command(subcommand)]
1448 subcommand: Option<SecuritySubcommand>,
1449 #[arg(long, value_name = "PATH")]
1454 runtime_coverage: Option<PathBuf>,
1455 #[arg(long, default_value_t = 100)]
1458 min_invocations_hot: u64,
1459 #[arg(long, value_name = "PATH")]
1463 file: Vec<std::path::PathBuf>,
1464 #[arg(long, value_name = "MODE")]
1470 gate: Option<security::SecurityGateArg>,
1471 #[arg(long)]
1473 surface: bool,
1474 },
1475
1476 Report {
1481 #[arg(long, value_name = "PATH")]
1484 from: PathBuf,
1485 },
1486 Schema,
1488
1489 CiTemplate {
1496 #[command(subcommand)]
1497 subcommand: CiTemplateCli,
1498 },
1499
1500 Migrate {
1502 #[arg(long, conflicts_with = "jsonc")]
1504 toml: bool,
1505
1506 #[arg(long)]
1514 jsonc: bool,
1515
1516 #[arg(long)]
1518 dry_run: bool,
1519
1520 #[arg(long, value_name = "PATH")]
1522 from: Option<PathBuf>,
1523 },
1524
1525 License {
1532 #[command(subcommand)]
1533 subcommand: LicenseCli,
1534 },
1535
1536 Telemetry {
1544 #[command(subcommand)]
1545 subcommand: TelemetryCli,
1546 },
1547
1548 Coverage {
1554 #[command(subcommand)]
1555 subcommand: CoverageCli,
1556 },
1557
1558 SetupHooks {
1573 #[arg(long, value_enum)]
1575 agent: Option<setup_hooks::HookAgentArg>,
1576
1577 #[arg(long)]
1579 dry_run: bool,
1580
1581 #[arg(long)]
1584 force: bool,
1585
1586 #[arg(long)]
1588 user: bool,
1589
1590 #[arg(long)]
1592 gitignore_claude: bool,
1593
1594 #[arg(long)]
1598 uninstall: bool,
1599 },
1600
1601 Viz {
1603 #[arg(long = "out", value_name = "PATH")]
1605 output: Option<PathBuf>,
1606
1607 #[arg(long)]
1609 no_open: bool,
1610
1611 #[arg(long = "viz-format", default_value = "html")]
1613 viz_format: viz::VizFormat,
1614 },
1615}
1616
1617#[derive(Subcommand)]
1618enum SecuritySubcommand {
1619 Survivors {
1621 #[arg(long, value_name = "PATH")]
1623 candidates: PathBuf,
1624 #[arg(long, value_name = "PATH")]
1626 verdicts: PathBuf,
1627 #[arg(long)]
1629 require_verdict_for_each_candidate: bool,
1630 },
1631 #[command(name = "blind-spots")]
1633 BlindSpots {
1634 #[arg(long, value_name = "PATH")]
1636 file: Vec<PathBuf>,
1637 },
1638}
1639
1640#[derive(clap::Subcommand)]
1641enum AuditCacheCli {
1642 Remove {
1644 #[arg(long)]
1646 dry_run: bool,
1647
1648 #[arg(long, alias = "force")]
1650 yes: bool,
1651 },
1652}
1653
1654#[derive(clap::Subcommand)]
1655enum LicenseCli {
1656 Activate {
1661 #[arg(value_name = "JWT")]
1663 jwt: Option<String>,
1664
1665 #[arg(long, value_name = "PATH")]
1667 from_file: Option<PathBuf>,
1668
1669 #[arg(long, conflicts_with_all = ["jwt", "from_file"])]
1671 stdin: bool,
1672
1673 #[arg(long, requires = "email")]
1680 trial: bool,
1681
1682 #[arg(long, value_name = "ADDR")]
1684 email: Option<String>,
1685 },
1686 Status,
1688 Refresh,
1690 Deactivate,
1692}
1693
1694#[derive(Clone, Copy, clap::Subcommand)]
1695enum TelemetryCli {
1696 Status,
1698 Enable,
1700 Disable,
1702 Inspect {
1704 #[arg(long)]
1706 example: bool,
1707 },
1708}
1709
1710#[derive(clap::Subcommand)]
1711enum CiTemplateCli {
1712 Gitlab {
1714 #[arg(long, value_name = "DIR", num_args = 0..=1, default_missing_value = ".")]
1718 vendor: Option<PathBuf>,
1719
1720 #[arg(long)]
1722 force: bool,
1723 },
1724}
1725
1726#[derive(clap::Subcommand)]
1727enum CoverageCli {
1728 Setup {
1730 #[arg(short = 'y', long)]
1732 yes: bool,
1733
1734 #[arg(long)]
1736 non_interactive: bool,
1737
1738 #[arg(long)]
1740 json: bool,
1741 },
1742 Analyze {
1748 #[arg(long, value_name = "PATH", conflicts_with = "cloud")]
1750 runtime_coverage: Option<PathBuf>,
1751
1752 #[arg(long, visible_alias = "runtime-coverage-cloud")]
1754 cloud: bool,
1755
1756 #[arg(long, value_name = "KEY")]
1758 api_key: Option<String>,
1759
1760 #[arg(long, value_name = "URL")]
1762 api_endpoint: Option<String>,
1763
1764 #[arg(long, value_name = "OWNER/REPO")]
1770 repo: Option<String>,
1771
1772 #[arg(long, value_name = "ID")]
1774 project_id: Option<String>,
1775
1776 #[arg(long, value_name = "DAYS", default_value_t = 30)]
1778 coverage_period: u16,
1779
1780 #[arg(long, value_name = "ENV")]
1782 environment: Option<String>,
1783
1784 #[arg(long, value_name = "SHA")]
1786 commit_sha: Option<String>,
1787
1788 #[arg(long)]
1790 production: bool,
1791
1792 #[arg(long, default_value_t = 100)]
1794 min_invocations_hot: u64,
1795
1796 #[arg(long, value_name = "N")]
1798 min_observation_volume: Option<u32>,
1799
1800 #[arg(long, value_name = "RATIO")]
1802 low_traffic_threshold: Option<f64>,
1803
1804 #[arg(long)]
1806 top: Option<usize>,
1807
1808 #[arg(long)]
1810 blast_radius: bool,
1811
1812 #[arg(long)]
1814 importance: bool,
1815 },
1816 UploadInventory {
1827 #[arg(long, value_name = "KEY")]
1836 api_key: Option<String>,
1837
1838 #[arg(long, value_name = "URL")]
1843 api_endpoint: Option<String>,
1844
1845 #[arg(long, value_name = "PROJECT_ID")]
1850 project_id: Option<String>,
1851
1852 #[arg(long, value_name = "SHA")]
1857 git_sha: Option<String>,
1858
1859 #[arg(long)]
1865 allow_dirty: bool,
1866
1867 #[arg(long, value_name = "GLOB", num_args = 0..)]
1871 exclude_paths: Vec<String>,
1872
1873 #[arg(long, value_name = "PREFIX")]
1886 path_prefix: Option<String>,
1887
1888 #[arg(long)]
1890 dry_run: bool,
1891
1892 #[arg(long)]
1898 with_callers: bool,
1899
1900 #[arg(long)]
1904 ignore_upload_errors: bool,
1905 },
1906 UploadSourceMaps {
1919 #[arg(long, value_name = "PATH", default_value = "dist")]
1921 dir: PathBuf,
1922
1923 #[arg(long, value_name = "GLOB", default_value = "**/*.map")]
1925 include: String,
1926
1927 #[arg(long, value_name = "GLOB", default_value = "**/node_modules/**")]
1931 exclude: Vec<String>,
1932
1933 #[arg(long, value_name = "NAME")]
1937 repo: Option<String>,
1938
1939 #[arg(long, value_name = "SHA")]
1944 git_sha: Option<String>,
1945
1946 #[arg(long, value_name = "URL")]
1948 endpoint: Option<String>,
1949
1950 #[arg(long, value_name = "BOOL", default_value_t = true, action = clap::ArgAction::Set)]
1955 strip_path: bool,
1956
1957 #[arg(long)]
1959 dry_run: bool,
1960
1961 #[arg(long, value_name = "N", default_value_t = 4)]
1963 concurrency: usize,
1964
1965 #[arg(long)]
1967 fail_fast: bool,
1968 },
1969 UploadStaticFindings {
1976 #[arg(long, value_name = "KEY")]
1986 api_key: Option<String>,
1987
1988 #[arg(long, value_name = "URL")]
1993 api_endpoint: Option<String>,
1994
1995 #[arg(long, value_name = "PROJECT_ID")]
2000 project_id: Option<String>,
2001
2002 #[arg(long, value_name = "SHA")]
2007 git_sha: Option<String>,
2008
2009 #[arg(long)]
2015 allow_dirty: bool,
2016
2017 #[arg(long)]
2019 dry_run: bool,
2020
2021 #[arg(long)]
2025 ignore_upload_errors: bool,
2026 },
2027}
2028
2029#[derive(Subcommand)]
2030enum CiCli {
2031 PlanPrComment {
2033 #[arg(long)]
2035 body: PathBuf,
2036
2037 #[arg(long)]
2039 marker_id: String,
2040
2041 #[arg(long)]
2043 clean: bool,
2044
2045 #[arg(long)]
2047 existing_comment_id: Option<String>,
2048
2049 #[arg(long)]
2051 existing_body: Option<PathBuf>,
2052 },
2053
2054 PostPrComment {
2056 #[arg(long, value_enum)]
2058 provider: CiProviderArg,
2059
2060 #[arg(long)]
2062 pr: Option<String>,
2063
2064 #[arg(long)]
2066 mr: Option<String>,
2067
2068 #[arg(long)]
2070 body: PathBuf,
2071
2072 #[arg(long)]
2074 envelope: Option<PathBuf>,
2075
2076 #[arg(long)]
2078 marker_id: String,
2079
2080 #[arg(long)]
2082 clean: bool,
2083
2084 #[arg(long)]
2086 repo: Option<String>,
2087
2088 #[arg(long = "project-id")]
2090 project_id: Option<String>,
2091
2092 #[arg(long = "api-url")]
2094 api_url: Option<String>,
2095
2096 #[arg(long)]
2098 dry_run: bool,
2099 },
2100
2101 PostReview {
2103 #[arg(long, value_enum)]
2105 provider: CiProviderArg,
2106
2107 #[arg(long)]
2109 pr: Option<String>,
2110
2111 #[arg(long)]
2113 mr: Option<String>,
2114
2115 #[arg(long)]
2117 envelope: PathBuf,
2118
2119 #[arg(long)]
2121 repo: Option<String>,
2122
2123 #[arg(long = "project-id")]
2125 project_id: Option<String>,
2126
2127 #[arg(long = "api-url")]
2129 api_url: Option<String>,
2130
2131 #[arg(long)]
2133 dry_run: bool,
2134 },
2135
2136 PostCheckRun {
2138 #[arg(long, value_enum)]
2140 provider: CiProviderArg,
2141
2142 #[arg(long)]
2144 decision: PathBuf,
2145
2146 #[arg(long)]
2148 repo: String,
2149
2150 #[arg(long = "head-sha")]
2152 head_sha: String,
2153
2154 #[arg(long = "api-url")]
2156 api_url: Option<String>,
2157
2158 #[arg(long = "split-gates")]
2160 split_gates: bool,
2161
2162 #[arg(long)]
2164 dry_run: bool,
2165 },
2166
2167 ReconcileReview {
2169 #[arg(long, value_enum)]
2171 provider: CiProviderArg,
2172
2173 #[arg(long)]
2175 pr: Option<String>,
2176
2177 #[arg(long)]
2179 mr: Option<String>,
2180
2181 #[arg(long)]
2183 envelope: PathBuf,
2184
2185 #[arg(long)]
2187 repo: Option<String>,
2188
2189 #[arg(long = "project-id")]
2191 project_id: Option<String>,
2192
2193 #[arg(long = "api-url")]
2195 api_url: Option<String>,
2196
2197 #[arg(long)]
2199 dry_run: bool,
2200 },
2201}
2202
2203#[derive(Subcommand)]
2204enum RulePackCli {
2205 Init {
2207 name: Option<String>,
2209
2210 #[arg(long, default_value = "starter")]
2212 template: String,
2213
2214 #[arg(long, default_value = "rule-packs")]
2216 dir: String,
2217
2218 #[arg(long)]
2220 no_config: bool,
2221 },
2222
2223 List,
2225
2226 Test {
2228 pack: Option<PathBuf>,
2230 },
2231
2232 Schema,
2234}
2235
2236#[derive(Clone, Copy, Debug, clap::ValueEnum)]
2237enum CiProviderArg {
2238 Github,
2239 Gitlab,
2240}
2241
2242#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2244pub enum EffortFilter {
2245 Low,
2246 Medium,
2247 High,
2248}
2249
2250impl EffortFilter {
2251 const fn to_estimate(self) -> fallow_output::EffortEstimate {
2253 match self {
2254 Self::Low => fallow_output::EffortEstimate::Low,
2255 Self::Medium => fallow_output::EffortEstimate::Medium,
2256 Self::High => fallow_output::EffortEstimate::High,
2257 }
2258 }
2259}
2260
2261#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2263pub enum HealthSeverityCli {
2264 Moderate,
2265 High,
2266 Critical,
2267}
2268
2269impl HealthSeverityCli {
2270 const fn to_health_severity(self) -> fallow_output::FindingSeverity {
2272 match self {
2273 Self::Moderate => fallow_output::FindingSeverity::Moderate,
2274 Self::High => fallow_output::FindingSeverity::High,
2275 Self::Critical => fallow_output::FindingSeverity::Critical,
2276 }
2277 }
2278}
2279
2280#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2286pub enum EmailModeArg {
2287 Raw,
2289 Handle,
2291 Anonymized,
2293 #[value(hide = true)]
2295 Hash,
2296}
2297
2298impl EmailModeArg {
2299 const fn to_config(self) -> fallow_config::EmailMode {
2301 match self {
2302 Self::Raw => fallow_config::EmailMode::Raw,
2303 Self::Handle => fallow_config::EmailMode::Handle,
2304 Self::Anonymized => fallow_config::EmailMode::Anonymized,
2305 Self::Hash => fallow_config::EmailMode::Hash,
2306 }
2307 }
2308}
2309
2310#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2312pub enum AuditGateArg {
2313 NewOnly,
2315 All,
2317}
2318
2319impl From<AuditGateArg> for fallow_config::AuditGate {
2320 fn from(value: AuditGateArg) -> Self {
2321 match value {
2322 AuditGateArg::NewOnly => Self::NewOnly,
2323 AuditGateArg::All => Self::All,
2324 }
2325 }
2326}
2327
2328fn parse_min_occurrences(s: &str) -> Result<usize, String> {
2332 let value: usize = s
2333 .parse()
2334 .map_err(|_| format!("`{s}` is not a non-negative integer"))?;
2335 if value < 2 {
2336 return Err(format!(
2337 "must be at least 2 (got {value}); a single occurrence isn't a duplicate"
2338 ));
2339 }
2340 Ok(value)
2341}
2342
2343fn resolve_audit_baseline_path(
2349 root: &std::path::Path,
2350 cli: Option<&std::path::Path>,
2351 config: Option<&str>,
2352) -> Option<PathBuf> {
2353 let path = cli.map(std::path::Path::to_path_buf).or_else(|| {
2354 config.map(|p| {
2355 let path = PathBuf::from(p);
2356 if path_util::is_absolute_path_any_platform(&path) {
2357 path
2358 } else {
2359 root.join(path)
2360 }
2361 })
2362 })?;
2363 if path_util::is_absolute_path_any_platform(&path) {
2364 Some(path)
2365 } else {
2366 Some(root.join(path))
2367 }
2368}
2369
2370fn emit_known_failure(
2371 message: &str,
2372 exit_code: u8,
2373 output: fallow_config::OutputFormat,
2374 reason: telemetry::FailureReason,
2375) -> ExitCode {
2376 telemetry::note_failure_reason(reason);
2377 emit_error(message, exit_code, output)
2378}
2379
2380fn emit_known_failure_with_style(
2381 message: &str,
2382 exit_code: u8,
2383 output: fallow_config::OutputFormat,
2384 json_style: json_style::JsonStyle,
2385 reason: telemetry::FailureReason,
2386) -> ExitCode {
2387 telemetry::note_failure_reason(reason);
2388 error::emit_error_with_style(message, exit_code, output, json_style)
2389}
2390
2391fn unsupported_security_global(cli: &Cli) -> Option<&'static str> {
2392 if cli.baseline.is_some() {
2393 Some("--baseline")
2394 } else if cli.save_baseline.is_some() {
2395 Some("--save-baseline")
2396 } else if cli.production {
2397 Some("--production")
2398 } else if cli.no_production {
2399 Some("--no-production")
2400 } else if cli.group_by.is_some() {
2401 Some("--group-by")
2402 } else if cli.performance {
2403 Some("--performance")
2404 } else if cli.explain_skipped {
2405 Some("--explain-skipped")
2406 } else if cli.fail_on_regression {
2407 Some("--fail-on-regression")
2408 } else if cli.regression_baseline.is_some() {
2409 Some("--regression-baseline")
2410 } else if cli.save_regression_baseline.is_some() {
2411 Some("--save-regression-baseline")
2412 } else if cli.dupes_mode.is_some() {
2413 Some("--dupes-mode")
2414 } else if cli.dupes_threshold.is_some() {
2415 Some("--dupes-threshold")
2416 } else if cli.dupes_min_tokens.is_some() {
2417 Some("--dupes-min-tokens")
2418 } else if cli.dupes_min_lines.is_some() {
2419 Some("--dupes-min-lines")
2420 } else if cli.dupes_min_occurrences.is_some() {
2421 Some("--dupes-min-occurrences")
2422 } else if cli.dupes_skip_local {
2423 Some("--dupes-skip-local")
2424 } else if cli.dupes_cross_language {
2425 Some("--dupes-cross-language")
2426 } else if cli.dupes_ignore_imports {
2427 Some("--dupes-ignore-imports")
2428 } else if cli.dupes_no_ignore_imports {
2429 Some("--dupes-no-ignore-imports")
2430 } else if cli.include_entry_exports {
2431 Some("--include-entry-exports")
2432 } else {
2433 None
2434 }
2435}
2436
2437struct DispatchContext<'a> {
2438 cli: &'a Cli,
2439 root: &'a std::path::Path,
2440 output: fallow_config::OutputFormat,
2441 quiet: bool,
2442 fail_on_issues: bool,
2443 json_style: json_style::JsonStyle,
2444 threads: usize,
2445 tolerance: regression::Tolerance,
2446 save_regression_file: Option<&'a std::path::PathBuf>,
2447 save_to_config: bool,
2448}
2449
2450impl DispatchContext<'_> {
2451 fn production_modes(
2452 &self,
2453 dead_code: bool,
2454 health: bool,
2455 dupes: bool,
2456 ) -> Result<ProductionModes, ExitCode> {
2457 resolve_production_modes(self.cli, self.root, self.output, dead_code, health, dupes)
2458 }
2459
2460 fn production_for(
2461 &self,
2462 analysis: fallow_config::ProductionAnalysis,
2463 ) -> Result<bool, ExitCode> {
2464 self.production_modes(false, false, false)
2465 .map(|modes| modes.for_analysis(analysis))
2466 }
2467
2468 fn regression_opts(&self, scoped: bool) -> regression::RegressionOpts<'_> {
2469 regression::RegressionOpts {
2470 fail_on_regression: self.cli.fail_on_regression,
2471 tolerance: self.tolerance,
2472 regression_baseline_file: self.cli.regression_baseline.as_deref(),
2473 save_target: if let Some(path) = self.save_regression_file {
2474 regression::SaveRegressionTarget::File(path)
2475 } else if self.save_to_config {
2476 regression::SaveRegressionTarget::Config
2477 } else {
2478 regression::SaveRegressionTarget::None
2479 },
2480 scoped,
2481 quiet: self.quiet,
2482 output: self.output,
2483 }
2484 }
2485}
2486
2487#[cfg(unix)]
2502fn signal_test_helper() -> ExitCode {
2503 use std::io::Write as _;
2504 use std::process::Command;
2505
2506 if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER_GRACEFUL").is_some() {
2507 signal::set_graceful_mode();
2508 }
2509
2510 let mut command = Command::new("sleep");
2511 command.arg("30");
2512 let child = match signal::ScopedChild::spawn(&mut command) {
2513 Ok(c) => c,
2514 Err(err) => {
2515 let _ = writeln!(std::io::stderr(), "spawn sleep failed: {err}");
2516 return ExitCode::from(2);
2517 }
2518 };
2519 let pid = child.id();
2520 let stdout = std::io::stdout();
2521 let mut lock = stdout.lock();
2522 let _ = writeln!(lock, "{pid}");
2523 let _ = lock.flush();
2524 drop(lock);
2525 let _ = child.wait_with_output();
2526 if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER_GRACEFUL").is_some() {
2527 return ExitCode::SUCCESS;
2528 }
2529 std::thread::sleep(std::time::Duration::from_secs(5));
2530 ExitCode::SUCCESS
2531}
2532
2533#[cfg(not(unix))]
2534fn signal_test_helper() -> ExitCode {
2535 ExitCode::from(2)
2536}
2537
2538fn install_spawn_hooks() {
2539 fallow_engine::churn::set_spawn_hook(signal::scoped_child::output);
2540 fallow_engine::changed_files::set_spawn_hook(signal::scoped_child::output);
2541}
2542
2543fn install_signal_handlers() {
2544 if let Err(err) = signal::install_handlers() {
2545 use std::io::Write as _;
2546 let stderr = std::io::stderr();
2547 let mut lock = stderr.lock();
2548 let _ = writeln!(lock, "fallow: failed to install signal handlers: {err}");
2549 }
2550}
2551
2552fn redirect_report_to_file(
2557 path: &std::path::Path,
2558 output: fallow_config::OutputFormat,
2559) -> Result<(), ExitCode> {
2560 if let Some(parent) = path.parent()
2561 && !parent.as_os_str().is_empty()
2562 && let Err(e) = std::fs::create_dir_all(parent)
2563 {
2564 return Err(emit_error(
2565 &format!(
2566 "failed to create {} for --output-file: {e}",
2567 parent.display()
2568 ),
2569 2,
2570 output,
2571 ));
2572 }
2573 match std::fs::File::create(path) {
2574 Ok(file) => {
2575 report::sink::set_file_sink(file);
2576 colored::control::set_override(false);
2577 Ok(())
2578 }
2579 Err(e) => Err(emit_error(
2580 &format!("failed to open {} for --output-file: {e}", path.display()),
2581 2,
2582 output,
2583 )),
2584 }
2585}
2586
2587fn finalize_report_file(
2590 path: &std::path::Path,
2591 quiet: bool,
2592 output: fallow_config::OutputFormat,
2593) -> Result<(), ExitCode> {
2594 if let Err(e) = report::sink::flush() {
2595 return Err(emit_error(
2596 &format!("failed to write {}: {e}", path.display()),
2597 2,
2598 output,
2599 ));
2600 }
2601 if !quiet && report::sink::wrote() {
2605 eprintln!("Report written to {}", path.display());
2606 }
2607 Ok(())
2608}
2609
2610pub fn run() -> ExitCode {
2615 install_signal_handlers();
2616 install_spawn_hooks();
2617
2618 if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER").is_some() {
2619 return signal_test_helper();
2620 }
2621
2622 let (mut cli, fmt) = match parse_cli_args() {
2623 Ok(parsed) => parsed,
2624 Err(code) => return code,
2625 };
2626 if cli.pretty && !fmt.payload_is_json {
2627 eprintln!(
2628 "Error: --pretty requires JSON output. Use --format json --pretty, or remove --pretty."
2629 );
2630 return ExitCode::from(2);
2631 }
2632
2633 if let Some(code) = run_schema_command_if_requested(&cli, fmt.json_style) {
2634 return code;
2635 }
2636
2637 if let Some(code) = run_telemetry_command_if_requested(&mut cli, fmt.output, fmt.json_style) {
2638 return code;
2639 }
2640 let telemetry_run = start_telemetry_run(&cli, &fmt);
2641
2642 let (root, threads) = match validate_inputs(&cli, fmt.output, fmt.json_style) {
2643 Ok(v) => v,
2644 Err(code) => {
2645 return record_run_epilogue(telemetry_run, code, None, cli.parent_run.as_deref());
2646 }
2647 };
2648
2649 let FormatConfig {
2650 output,
2651 payload_is_json: _,
2652 quiet,
2653 fail_on_issues,
2654 json_style,
2655 } = fmt;
2656
2657 let tolerance =
2658 match run_pre_dispatch_checks(&cli, &root, output, json_style, quiet, telemetry_run) {
2659 Ok(tolerance) => tolerance,
2660 Err(code) => return code,
2661 };
2662
2663 let (save_regression_file, save_to_config) = regression_save_targets(&cli);
2664
2665 let command = cli.command.take();
2666 let dispatch = DispatchContext {
2667 cli: &cli,
2668 root: &root,
2669 output,
2670 quiet,
2671 fail_on_issues,
2672 json_style,
2673 threads,
2674 tolerance,
2675 save_regression_file: save_regression_file.as_ref(),
2676 save_to_config,
2677 };
2678 let exit_code = match dispatch_and_finalize(&dispatch, command) {
2679 Ok(code) => code,
2680 Err(code) => return code,
2681 };
2682 record_run_epilogue(telemetry_run, exit_code, None, cli.parent_run.as_deref())
2683}
2684
2685fn dispatch_and_finalize(
2689 dispatch: &DispatchContext<'_>,
2690 command: Option<Command>,
2691) -> Result<ExitCode, ExitCode> {
2692 let cli = dispatch.cli;
2693 let output = dispatch.output;
2694 let quiet = dispatch.quiet;
2695
2696 if let Some(path) = cli.output_file.as_deref()
2699 && let Err(code) = redirect_report_to_file(path, output)
2700 {
2701 return Err(code);
2702 }
2703
2704 let exit_code = if command.is_some() && cli_has_bare_coverage_input(cli) {
2705 emit_error(bare_coverage_subcommand_error_message(), 2, output)
2706 } else {
2707 match command {
2708 None => dispatch_bare_command(dispatch),
2709 Some(cmd) => dispatch_subcommand(cmd, dispatch),
2710 }
2711 };
2712
2713 if let Some(path) = cli.output_file.as_deref()
2714 && let Err(code) = finalize_report_file(path, quiet, output)
2715 {
2716 return Err(code);
2717 }
2718 Ok(exit_code)
2719}
2720
2721fn run_telemetry_command_if_requested(
2722 cli: &mut Cli,
2723 output: fallow_config::OutputFormat,
2724 json_style: json_style::JsonStyle,
2725) -> Option<ExitCode> {
2726 if matches!(cli.command, Some(Command::Telemetry { .. }))
2727 && let Some(Command::Telemetry { subcommand }) = cli.command.take()
2728 {
2729 return Some(telemetry::run(
2730 map_telemetry_subcommand(subcommand),
2731 output,
2732 json_style,
2733 ));
2734 }
2735 None
2736}
2737
2738fn run_schema_command_if_requested(
2739 cli: &Cli,
2740 json_style: json_style::JsonStyle,
2741) -> Option<ExitCode> {
2742 match cli.command {
2743 Some(Command::Schema) => Some(schema::run_schema(json_style)),
2744 Some(Command::ConfigSchema) => Some(init::run_config_schema(json_style)),
2745 Some(Command::PluginSchema) => Some(init::run_plugin_schema(json_style)),
2746 Some(Command::RulePackSchema) => Some(init::run_rule_pack_schema(json_style)),
2747 _ => None,
2748 }
2749}
2750
2751fn regression_save_targets(cli: &Cli) -> (Option<std::path::PathBuf>, bool) {
2752 let save_file = cli.save_regression_baseline.as_ref().and_then(|opt| {
2753 opt.as_ref()
2754 .filter(|path| !path.is_empty())
2755 .map(std::path::PathBuf::from)
2756 });
2757 let save_to_config = cli.save_regression_baseline.is_some() && save_file.is_none();
2758 (save_file, save_to_config)
2759}
2760
2761fn dispatch_bare_command(dispatch: &DispatchContext<'_>) -> ExitCode {
2762 let cli = dispatch.cli;
2763 let (run_check, run_dupes, run_health) = combined::resolve_analyses(&cli.only, &cli.skip);
2764 let production = match dispatch.production_modes(
2765 cli.production_dead_code,
2766 cli.production_health,
2767 cli.production_dupes,
2768 ) {
2769 Ok(production) => production,
2770 Err(code) => return code,
2771 };
2772 let coverage_inputs = match resolve_health_coverage_inputs(
2773 dispatch,
2774 cli.coverage.as_deref(),
2775 cli.coverage_root.as_deref(),
2776 ) {
2777 Ok(inputs) => inputs,
2778 Err(code) => return code,
2779 };
2780 run_bare_combined(
2781 dispatch,
2782 production,
2783 &coverage_inputs,
2784 BareAnalyses {
2785 run_check,
2786 run_dupes,
2787 run_health,
2788 },
2789 )
2790}
2791
2792#[derive(Clone, Copy)]
2794struct BareAnalyses {
2795 run_check: bool,
2796 run_dupes: bool,
2797 run_health: bool,
2798}
2799
2800fn run_bare_combined(
2803 dispatch: &DispatchContext<'_>,
2804 production: ProductionModes,
2805 coverage_inputs: &ResolvedHealthCoverageInputs,
2806 analyses: BareAnalyses,
2807) -> ExitCode {
2808 let cli = dispatch.cli;
2809 let (output, quiet, fail_on_issues) =
2810 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
2811 combined::run_combined(&combined::CombinedOptions {
2812 root: dispatch.root,
2813 config_path: &cli.config,
2814 output,
2815 json_style: dispatch.json_style,
2816 no_cache: cli.no_cache,
2817 threads: dispatch.threads,
2818 quiet,
2819 allow_remote_extends: cli.allow_remote_extends,
2820 fail_on_issues,
2821 sarif_file: cli.sarif_file.as_deref(),
2822 changed_since: cli.changed_since.as_deref(),
2823 churn_file: cli.churn_file.as_deref(),
2824 baseline: cli.baseline.as_deref(),
2825 save_baseline: cli.save_baseline.as_deref(),
2826 production: cli.production,
2827 production_dead_code: Some(production.dead_code),
2828 production_health: Some(production.health),
2829 production_dupes: Some(production.dupes),
2830 workspace: cli.workspace.as_deref(),
2831 changed_workspaces: cli.changed_workspaces.as_deref(),
2832 group_by: cli.group_by,
2833 explain: cli.explain,
2834 explain_skipped: cli.explain_skipped,
2835 performance: cli.performance,
2836 summary: cli.summary,
2837 run_check: analyses.run_check,
2838 run_dupes: analyses.run_dupes,
2839 run_health: analyses.run_health,
2840 dupes_mode: cli.dupes_mode,
2841 dupes_threshold: cli.dupes_threshold,
2842 dupes_min_tokens: cli.dupes_min_tokens,
2843 dupes_min_lines: cli.dupes_min_lines,
2844 dupes_min_occurrences: cli.dupes_min_occurrences,
2845 dupes_skip_local: cli.dupes_skip_local,
2846 dupes_cross_language: cli.dupes_cross_language,
2847 dupes_ignore_imports: resolve_ignore_imports(
2848 cli.dupes_ignore_imports,
2849 cli.dupes_no_ignore_imports,
2850 ),
2851 score: cli.score || cli.trend,
2852 trend: cli.trend,
2853 save_snapshot: cli.save_snapshot.as_ref(),
2854 coverage: coverage_inputs.coverage.as_deref(),
2855 coverage_root: coverage_inputs.coverage_root.as_deref(),
2856 include_entry_exports: cli.include_entry_exports,
2857 regression_opts: dispatch.regression_opts(
2858 cli.changed_since.is_some()
2859 || cli.workspace.is_some()
2860 || cli.changed_workspaces.is_some(),
2861 ),
2862 })
2863}
2864
2865fn dispatch_subcommand(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
2866 let cli = dispatch.cli;
2867 let root = dispatch.root;
2868 let output = dispatch.output;
2869 let quiet = dispatch.quiet;
2870 match command {
2871 check @ Command::Check { .. } => dispatch_check_command(check, dispatch),
2872 Command::Watch { no_clear } => dispatch_watch(dispatch, no_clear),
2873 Command::Inspect {
2874 file,
2875 symbol,
2876 symbol_chain,
2877 churn,
2878 } => dispatch_inspect_command(dispatch, file, symbol, symbol_chain, churn),
2879 Command::Trace {
2880 symbol,
2881 callers,
2882 callees,
2883 depth,
2884 } => dispatch_trace_command(dispatch, symbol, callers, callees, depth),
2885 fix @ Command::Fix { .. } => dispatch_fix_command(&fix, dispatch),
2886 init @ Command::Init { .. } => dispatch_init_command(init, root, quiet),
2887 Command::Hooks { subcommand } => {
2888 run_hooks_command(root, subcommand, output, dispatch.json_style)
2889 }
2890 Command::Ci { subcommand } => {
2891 ci::run(map_ci_subcommand(subcommand), output, dispatch.json_style)
2892 }
2893 Command::ConfigSchema => init::run_config_schema(dispatch.json_style),
2894 Command::PluginSchema => init::run_plugin_schema(dispatch.json_style),
2895 Command::PluginCheck => plugin_check::run_plugin_check(root, output, dispatch.json_style),
2896 Command::RulePackSchema => init::run_rule_pack_schema(dispatch.json_style),
2897 Command::RulePack { subcommand } => dispatch_rule_pack_command(dispatch, subcommand),
2898 Command::Guard { files } => dispatch_guard_command(dispatch, &files),
2899 Command::CiTemplate { subcommand } => dispatch_ci_template_command(subcommand),
2900 Command::Config { path } => config::run_config_with_options(config::RunConfigInput {
2901 root,
2902 explicit_config: cli.config.as_deref(),
2903 path_only: path,
2904 output,
2905 quiet,
2906 json_style: dispatch.json_style,
2907 load_options: fallow_config::ConfigLoadOptions {
2908 allow_remote_extends: cli.allow_remote_extends,
2909 },
2910 }),
2911 Command::Recommend => onboarding::run_recommend(root, output, dispatch.json_style),
2912 list @ (Command::Workspaces | Command::List { .. }) => {
2913 dispatch_list_command(&list, dispatch)
2914 }
2915 dupes @ Command::Dupes { .. } => dispatch_dupes_command(dupes, dispatch),
2916 health @ Command::Health { .. } => dispatch_health_command(health, dispatch),
2917 Command::Flags { top } => dispatch_flags_command(dispatch, top),
2918 Command::Suppressions { file } => dispatch_suppressions_command(dispatch, &file),
2919 Command::Explain { issue_type } => {
2920 explain::run_explain(&issue_type.join(" "), output, dispatch.json_style)
2921 }
2922 audit @ Command::Audit { .. } => dispatch_audit_command(audit, dispatch),
2923 Command::AuditCache { subcommand } => dispatch_audit_cache_command(dispatch, &subcommand),
2924 Command::DecisionSurface { max_decisions } => {
2925 dispatch_decision_surface(dispatch, max_decisions)
2926 }
2927 Command::Impact {
2928 subcommand,
2929 all,
2930 sort,
2931 limit,
2932 } => dispatch_impact(
2933 root,
2934 quiet,
2935 output,
2936 dispatch.json_style,
2937 subcommand,
2938 ImpactCrossRepoOpts { all, sort, limit },
2939 ),
2940 security @ Command::Security { .. } => dispatch_security_command(security, dispatch),
2941 Command::Viz {
2942 output: viz_output,
2943 no_open,
2944 viz_format,
2945 } => dispatch_viz(dispatch, viz_output.as_deref(), no_open, viz_format),
2946 Command::Report { from } => cli_report::run_report(&from, output, root),
2947 Command::Schema => unreachable!("handled above"),
2948 migrate @ Command::Migrate { .. } => dispatch_migrate_command(migrate, root),
2949 Command::License { subcommand } => {
2950 dispatch_license_command(subcommand, output, dispatch.json_style)
2951 }
2952 Command::Telemetry { .. } => unreachable!("handled before root validation"),
2953 Command::Coverage { subcommand } => dispatch_coverage_command(dispatch, &subcommand),
2954 setup_hooks @ Command::SetupHooks { .. } => {
2955 dispatch_setup_hooks_command(&setup_hooks, dispatch)
2956 }
2957 }
2958}
2959
2960fn dispatch_check_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
2962 let filters = check_issue_filters(&command);
2963 let Command::Check {
2964 include_dupes,
2965 trace,
2966 trace_file,
2967 trace_dependency,
2968 impact_closure,
2969 top,
2970 file,
2971 ..
2972 } = command
2973 else {
2974 unreachable!("check dispatcher only handles check commands");
2975 };
2976
2977 dispatch_check(
2978 dispatch,
2979 &CheckDispatchArgs {
2980 filters,
2981 trace_opts: TraceOptions {
2982 trace_export: trace,
2983 trace_file,
2984 trace_dependency,
2985 impact_closure,
2986 performance: dispatch.cli.performance,
2987 },
2988 include_dupes,
2989 top,
2990 file,
2991 },
2992 )
2993}
2994
2995fn check_issue_filters(command: &Command) -> IssueFilters {
3000 check_issue_filters_framework(command, &check_issue_filters_core(command))
3001}
3002
3003fn check_issue_filters_core(command: &Command) -> IssueFilters {
3006 let Command::Check {
3007 unused_files,
3008 unused_exports,
3009 unused_deps,
3010 unused_types,
3011 private_type_leaks,
3012 unused_enum_members,
3013 unused_class_members,
3014 unresolved_imports,
3015 unlisted_deps,
3016 duplicate_exports,
3017 circular_deps,
3018 re_export_cycles,
3019 boundary_violations,
3020 policy_violations,
3021 stale_suppressions,
3022 ..
3023 } = command
3024 else {
3025 unreachable!("check filter builder only handles check commands");
3026 };
3027
3028 let mut filters = IssueFilters::default();
3029 for (flag, active) in [
3030 ("--unused-files", *unused_files),
3031 ("--unused-exports", *unused_exports),
3032 ("--unused-deps", *unused_deps),
3033 ("--unused-types", *unused_types),
3034 ("--private-type-leaks", *private_type_leaks),
3035 ("--unused-enum-members", *unused_enum_members),
3036 ("--unused-class-members", *unused_class_members),
3037 ("--unresolved-imports", *unresolved_imports),
3038 ("--unlisted-deps", *unlisted_deps),
3039 ("--duplicate-exports", *duplicate_exports),
3040 ("--circular-deps", *circular_deps),
3041 ("--re-export-cycles", *re_export_cycles),
3042 ("--boundary-violations", *boundary_violations),
3043 ("--policy-violations", *policy_violations),
3044 ("--stale-suppressions", *stale_suppressions),
3045 ] {
3046 enable_check_filter(&mut filters, flag, active);
3047 }
3048 filters
3049}
3050
3051fn check_issue_filters_framework(command: &Command, base: &IssueFilters) -> IssueFilters {
3054 let Command::Check {
3055 unused_store_members,
3056 unprovided_injects,
3057 unrendered_components,
3058 unused_component_props,
3059 unused_component_emits,
3060 unused_component_inputs,
3061 unused_component_outputs,
3062 unused_svelte_events,
3063 unused_server_actions,
3064 unused_load_data_keys,
3065 unused_catalog_entries,
3066 empty_catalog_groups,
3067 unresolved_catalog_references,
3068 unused_dependency_overrides,
3069 misconfigured_dependency_overrides,
3070 ..
3071 } = command
3072 else {
3073 unreachable!("check filter builder only handles check commands");
3074 };
3075
3076 let mut filters = base.clone();
3077 for (flag, active) in [
3078 ("--unused-store-members", *unused_store_members),
3079 ("--unprovided-injects", *unprovided_injects),
3080 ("--unrendered-components", *unrendered_components),
3081 ("--unused-component-props", *unused_component_props),
3082 ("--unused-component-emits", *unused_component_emits),
3083 ("--unused-component-inputs", *unused_component_inputs),
3084 ("--unused-component-outputs", *unused_component_outputs),
3085 ("--unused-svelte-events", *unused_svelte_events),
3086 ("--unused-server-actions", *unused_server_actions),
3087 ("--unused-load-data-keys", *unused_load_data_keys),
3088 ("--unused-catalog-entries", *unused_catalog_entries),
3089 ("--empty-catalog-groups", *empty_catalog_groups),
3090 (
3091 "--unresolved-catalog-references",
3092 *unresolved_catalog_references,
3093 ),
3094 (
3095 "--unused-dependency-overrides",
3096 *unused_dependency_overrides,
3097 ),
3098 (
3099 "--misconfigured-dependency-overrides",
3100 *misconfigured_dependency_overrides,
3101 ),
3102 ] {
3103 enable_check_filter(&mut filters, flag, active);
3104 }
3105 filters
3106}
3107
3108fn enable_check_filter(filters: &mut IssueFilters, flag: &str, active: bool) {
3109 if active {
3110 assert!(
3111 filters.enable_cli_filter_flag(flag),
3112 "check command uses unregistered dead-code filter flag {flag}"
3113 );
3114 }
3115}
3116
3117fn dispatch_inspect_command(
3118 dispatch: &DispatchContext<'_>,
3119 file: Option<String>,
3120 symbol: Option<String>,
3121 symbol_chain: bool,
3122 churn: bool,
3123) -> ExitCode {
3124 let target = match (file, symbol) {
3125 (Some(file), None) => inspect::InspectTarget::File { file },
3126 (None, Some(symbol)) => match symbol.rsplit_once(':') {
3127 Some((file, export_name))
3128 if !file.trim().is_empty() && !export_name.trim().is_empty() =>
3129 {
3130 inspect::InspectTarget::Symbol {
3131 file: file.to_string(),
3132 export_name: export_name.to_string(),
3133 }
3134 }
3135 _ => {
3136 return emit_error(
3137 "--symbol must be formatted as FILE:EXPORT",
3138 2,
3139 dispatch.output,
3140 );
3141 }
3142 },
3143 _ => {
3144 return emit_error(
3145 "inspect requires exactly one of --file or --symbol",
3146 2,
3147 dispatch.output,
3148 );
3149 }
3150 };
3151
3152 let churn_config = if churn {
3153 match load_config_for_analysis(
3154 dispatch.root,
3155 &dispatch.cli.config,
3156 ConfigLoadOptions {
3157 output: dispatch.output,
3158 no_cache: dispatch.cli.no_cache,
3159 threads: dispatch.threads,
3160 production_override: None,
3161 quiet: dispatch.quiet,
3162 allow_remote_extends: dispatch.cli.allow_remote_extends,
3163 },
3164 fallow_config::ProductionAnalysis::Health,
3165 ) {
3166 Ok(config) => Some(config),
3167 Err(code) => return code,
3168 }
3169 } else {
3170 None
3171 };
3172
3173 inspect::run_inspect(&inspect::InspectOptions {
3174 root: dispatch.root,
3175 config_path: dispatch.cli.config.as_ref(),
3176 output: dispatch.output,
3177 json_style: dispatch.json_style,
3178 no_cache: dispatch.cli.no_cache,
3179 no_production: dispatch.cli.no_production,
3180 max_file_size: dispatch.cli.max_file_size,
3181 threads: dispatch.threads,
3182 quiet: dispatch.quiet,
3183 production: dispatch.cli.production,
3184 workspace: dispatch.cli.workspace.as_ref(),
3185 target,
3186 churn_cache_dir: churn_config
3187 .as_ref()
3188 .map(|config| config.cache_dir.as_path()),
3189 symbol_chain,
3190 })
3191}
3192
3193fn dispatch_trace_command(
3194 dispatch: &DispatchContext<'_>,
3195 symbol: String,
3196 callers: bool,
3197 callees: bool,
3198 depth: Option<u32>,
3199) -> ExitCode {
3200 trace_chain::run_trace(&trace_chain::TraceChainOptions {
3201 root: dispatch.root,
3202 config_path: &dispatch.cli.config,
3203 output: dispatch.output,
3204 json_style: dispatch.json_style,
3205 no_cache: dispatch.cli.no_cache,
3206 threads: dispatch.threads,
3207 quiet: dispatch.quiet,
3208 allow_remote_extends: dispatch.cli.allow_remote_extends,
3209 target: symbol,
3210 callers,
3211 callees,
3212 depth: depth.unwrap_or(fallow_types::trace_chain::DEFAULT_TRACE_DEPTH),
3213 })
3214}
3215
3216fn dispatch_security_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3217 let Command::Security {
3218 subcommand,
3219 runtime_coverage,
3220 min_invocations_hot,
3221 file,
3222 gate,
3223 surface,
3224 } = command
3225 else {
3226 unreachable!("security dispatcher only handles security commands");
3227 };
3228
3229 let gate = gate.map(security::SecurityGateArg::into_mode);
3230 let cli = dispatch.cli;
3231 let (output, _quiet, fail_on_issues) =
3232 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
3233 let derived_flags = SecurityDerivedFlagState {
3234 output,
3235 json_style: dispatch.json_style,
3236 ci: cli.ci,
3237 fail_on_issues,
3238 sarif_file: cli.sarif_file.as_deref(),
3239 summary: cli.summary,
3240 explain: cli.explain,
3241 runtime_coverage: runtime_coverage.as_deref(),
3242 min_invocations_hot,
3243 file: file.as_slice(),
3244 gate,
3245 surface,
3246 };
3247 if let Some(code) = try_run_security_survivors(subcommand.as_ref(), &derived_flags) {
3248 return code;
3249 }
3250
3251 let scoped_files = scoped_security_files(&file, subcommand.as_ref());
3252 run_security_blind_spots_or_default(
3253 dispatch,
3254 &SecurityRunInputs {
3255 scoped_files: &scoped_files,
3256 subcommand: &subcommand,
3257 runtime_coverage: runtime_coverage.as_deref(),
3258 min_invocations_hot,
3259 gate,
3260 surface,
3261 },
3262 &derived_flags,
3263 )
3264}
3265
3266struct SecurityRunInputs<'a> {
3269 scoped_files: &'a [PathBuf],
3270 subcommand: &'a Option<SecuritySubcommand>,
3271 runtime_coverage: Option<&'a Path>,
3272 min_invocations_hot: u64,
3273 gate: Option<security::SecurityGateMode>,
3274 surface: bool,
3275}
3276
3277fn run_security_blind_spots_or_default(
3279 dispatch: &DispatchContext<'_>,
3280 inputs: &SecurityRunInputs<'_>,
3281 derived_flags: &SecurityDerivedFlagState<'_>,
3282) -> ExitCode {
3283 let cli = dispatch.cli;
3284 let (output, quiet, fail_on_issues) =
3285 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
3286 let opts = security::SecurityOptions {
3287 root: dispatch.root,
3288 config_path: &cli.config,
3289 output,
3290 json_style: dispatch.json_style,
3291 no_cache: cli.no_cache,
3292 threads: dispatch.threads,
3293 quiet,
3294 allow_remote_extends: cli.allow_remote_extends,
3295 fail_on_issues,
3296 sarif_file: cli.sarif_file.as_deref(),
3297 summary: cli.summary,
3298 changed_since: cli.changed_since.as_deref(),
3299 use_shared_diff_index: true,
3300 workspace: cli.workspace.as_deref(),
3301 changed_workspaces: cli.changed_workspaces.as_deref(),
3302 file: inputs.scoped_files,
3303 surface: inputs.surface,
3304 gate: inputs.gate,
3305 runtime_coverage: inputs.runtime_coverage,
3306 min_invocations_hot: inputs.min_invocations_hot,
3307 explain: cli.explain,
3308 };
3309 if matches!(
3310 inputs.subcommand,
3311 Some(SecuritySubcommand::BlindSpots { .. })
3312 ) {
3313 if let Some(code) = validate_security_blind_spots_flags(derived_flags) {
3314 return code;
3315 }
3316 security::run_blind_spots(&opts)
3317 } else {
3318 security::run(&opts)
3319 }
3320}
3321
3322fn try_run_security_survivors(
3325 subcommand: Option<&SecuritySubcommand>,
3326 flags: &SecurityDerivedFlagState<'_>,
3327) -> Option<ExitCode> {
3328 let Some(SecuritySubcommand::Survivors {
3329 candidates,
3330 verdicts,
3331 require_verdict_for_each_candidate,
3332 }) = subcommand
3333 else {
3334 return None;
3335 };
3336 if let Some(code) = validate_security_survivors_flags(flags) {
3337 return Some(code);
3338 }
3339 Some(security::run_survivors(
3340 &security::SecuritySurvivorsOptions {
3341 output: flags.output,
3342 json_style: flags.json_style,
3343 candidates,
3344 verdicts,
3345 require_verdict_for_each_candidate: *require_verdict_for_each_candidate,
3346 },
3347 ))
3348}
3349
3350fn scoped_security_files(
3352 file: &[PathBuf],
3353 subcommand: Option<&SecuritySubcommand>,
3354) -> Vec<PathBuf> {
3355 let mut scoped_files = file.to_vec();
3356 if let Some(SecuritySubcommand::BlindSpots {
3357 file: blind_spot_files,
3358 }) = subcommand
3359 {
3360 scoped_files.extend(blind_spot_files.iter().cloned());
3361 }
3362 scoped_files
3363}
3364
3365struct SecurityDerivedFlagState<'a> {
3366 output: fallow_config::OutputFormat,
3367 json_style: json_style::JsonStyle,
3368 ci: bool,
3369 fail_on_issues: bool,
3370 sarif_file: Option<&'a Path>,
3371 summary: bool,
3372 explain: bool,
3373 runtime_coverage: Option<&'a Path>,
3374 min_invocations_hot: u64,
3375 file: &'a [PathBuf],
3376 gate: Option<security::SecurityGateMode>,
3377 surface: bool,
3378}
3379
3380fn validate_security_survivors_flags(flags: &SecurityDerivedFlagState<'_>) -> Option<ExitCode> {
3381 let flag = if flags.ci {
3382 Some("--ci")
3383 } else if flags.fail_on_issues {
3384 Some("--fail-on-issues")
3385 } else if flags.sarif_file.is_some() {
3386 Some("--sarif-file")
3387 } else if flags.summary {
3388 Some("--summary")
3389 } else if flags.explain {
3390 Some("--explain")
3391 } else if flags.runtime_coverage.is_some() {
3392 Some("--runtime-coverage")
3393 } else if flags.min_invocations_hot != DEFAULT_MIN_INVOCATIONS_HOT {
3394 Some("--min-invocations-hot")
3395 } else if !flags.file.is_empty() {
3396 Some("--file")
3397 } else if flags.gate.is_some() {
3398 Some("--gate")
3399 } else if flags.surface {
3400 Some("--surface")
3401 } else {
3402 None
3403 }?;
3404 Some(emit_error(
3405 &format!("{flag} is not valid with `fallow security survivors`."),
3406 2,
3407 flags.output,
3408 ))
3409}
3410
3411fn validate_security_blind_spots_flags(flags: &SecurityDerivedFlagState<'_>) -> Option<ExitCode> {
3412 let flag = if flags.ci {
3413 Some("--ci")
3414 } else if flags.fail_on_issues {
3415 Some("--fail-on-issues")
3416 } else if flags.sarif_file.is_some() {
3417 Some("--sarif-file")
3418 } else if flags.summary {
3419 Some("--summary")
3420 } else if flags.explain {
3421 Some("--explain")
3422 } else if flags.runtime_coverage.is_some() {
3423 Some("--runtime-coverage")
3424 } else if flags.min_invocations_hot != DEFAULT_MIN_INVOCATIONS_HOT {
3425 Some("--min-invocations-hot")
3426 } else if flags.gate.is_some() {
3427 Some("--gate")
3428 } else if flags.surface {
3429 Some("--surface")
3430 } else {
3431 None
3432 }?;
3433 Some(emit_error(
3434 &format!("{flag} is not valid with `fallow security blind-spots`."),
3435 2,
3436 flags.output,
3437 ))
3438}
3439
3440fn dispatch_dupes_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3441 let Command::Dupes {
3442 mode,
3443 min_tokens,
3444 min_lines,
3445 min_occurrences,
3446 threshold,
3447 skip_local,
3448 cross_language,
3449 ignore_imports,
3450 no_ignore_imports,
3451 top,
3452 trace,
3453 } = command
3454 else {
3455 unreachable!("dupes dispatcher only handles dupes commands");
3456 };
3457
3458 dispatch_dupes(
3459 dispatch,
3460 &DupesDispatchArgs {
3461 mode,
3462 min_tokens,
3463 min_lines,
3464 min_occurrences,
3465 threshold,
3466 skip_local,
3467 cross_language,
3468 ignore_imports,
3469 no_ignore_imports,
3470 top,
3471 trace,
3472 },
3473 )
3474}
3475
3476fn dispatch_init_command(command: Command, root: &Path, quiet: bool) -> ExitCode {
3477 let Command::Init {
3478 toml,
3479 agents,
3480 hooks,
3481 branch,
3482 decline,
3483 } = command
3484 else {
3485 unreachable!("init dispatcher only handles init commands");
3486 };
3487
3488 init::run_init(&init::InitOptions {
3489 root,
3490 use_toml: toml,
3491 agents,
3492 hooks,
3493 branch: branch.as_deref(),
3494 decline,
3495 quiet,
3496 })
3497}
3498
3499fn dispatch_fix_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3500 let Command::Fix {
3501 dry_run,
3502 yes,
3503 no_create_config,
3504 } = command
3505 else {
3506 unreachable!("fix dispatcher only handles fix commands");
3507 };
3508
3509 dispatch_fix(
3510 dispatch,
3511 FixDispatchArgs {
3512 dry_run: *dry_run,
3513 yes: *yes,
3514 no_create_config: *no_create_config,
3515 },
3516 )
3517}
3518
3519fn dispatch_list_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3520 match command {
3521 Command::Workspaces => dispatch_list(dispatch, ListDispatchArgs::workspaces()),
3522 Command::List {
3523 entry_points,
3524 files,
3525 plugins,
3526 boundaries,
3527 workspaces,
3528 } => dispatch_list(
3529 dispatch,
3530 ListDispatchArgs {
3531 entry_points: *entry_points,
3532 files: *files,
3533 plugins: *plugins,
3534 boundaries: *boundaries,
3535 workspaces: *workspaces,
3536 },
3537 ),
3538 _ => unreachable!("list dispatcher only handles list commands"),
3539 }
3540}
3541
3542fn dispatch_migrate_command(command: Command, root: &Path) -> ExitCode {
3543 let Command::Migrate {
3544 toml,
3545 jsonc,
3546 dry_run,
3547 from,
3548 } = command
3549 else {
3550 unreachable!("migrate dispatcher only handles migrate commands");
3551 };
3552
3553 migrate::run_migrate(root, toml, jsonc, dry_run, from.as_deref())
3554}
3555
3556fn dispatch_license_command(
3557 subcommand: LicenseCli,
3558 output: fallow_config::OutputFormat,
3559 json_style: json_style::JsonStyle,
3560) -> ExitCode {
3561 license::run(&map_license_subcommand(subcommand), output, json_style)
3562}
3563
3564fn dispatch_ci_template_command(subcommand: CiTemplateCli) -> ExitCode {
3565 match subcommand {
3566 CiTemplateCli::Gitlab { vendor, force } => {
3567 ci_template::run_gitlab_template(&ci_template::GitlabTemplateOptions {
3568 vendor_dir: vendor,
3569 force,
3570 })
3571 }
3572 }
3573}
3574
3575fn dispatch_coverage_command(dispatch: &DispatchContext<'_>, subcommand: &CoverageCli) -> ExitCode {
3576 let cli = dispatch.cli;
3577 coverage::run(
3578 map_coverage_subcommand(subcommand, cli.explain),
3579 &coverage::RunContext {
3580 root: dispatch.root,
3581 config_path: &cli.config,
3582 output: dispatch.output,
3583 json_style: dispatch.json_style,
3584 quiet: dispatch.quiet,
3585 no_cache: cli.no_cache,
3586 threads: dispatch.threads,
3587 explain: cli.explain,
3588 allow_remote_extends: cli.allow_remote_extends,
3589 },
3590 )
3591}
3592
3593fn dispatch_health_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3594 let Command::Health {
3595 max_cyclomatic,
3596 max_cognitive,
3597 max_crap,
3598 top,
3599 sort,
3600 complexity,
3601 complexity_breakdown,
3602 file_scores,
3603 coverage_gaps,
3604 hotspots,
3605 ownership,
3606 ownership_emails,
3607 targets,
3608 css,
3609 effort,
3610 score,
3611 min_score,
3612 min_severity,
3613 report_only,
3614 since,
3615 min_commits,
3616 save_snapshot,
3617 trend,
3618 coverage,
3619 coverage_root,
3620 runtime_coverage,
3621 min_invocations_hot,
3622 min_observation_volume,
3623 low_traffic_threshold,
3624 } = command
3625 else {
3626 unreachable!("health dispatcher only handles health commands");
3627 };
3628
3629 let ownership = ownership || ownership_emails.is_some();
3630 let hotspots = hotspots || ownership;
3631 let args = HealthDispatchArgs {
3632 max_cyclomatic,
3633 max_cognitive,
3634 max_crap,
3635 top,
3636 sort,
3637 complexity,
3638 complexity_breakdown,
3639 file_scores,
3640 coverage_gaps,
3641 hotspots,
3642 ownership,
3643 ownership_emails: ownership_emails.map(EmailModeArg::to_config),
3644 targets,
3645 css,
3646 effort,
3647 score,
3648 min_score,
3649 min_severity: min_severity.map(HealthSeverityCli::to_health_severity),
3650 report_only,
3651 since: since.as_deref(),
3652 min_commits,
3653 save_snapshot: save_snapshot.as_ref(),
3654 trend,
3655 coverage: coverage.as_deref(),
3656 coverage_root: coverage_root.as_deref(),
3657 runtime_coverage: runtime_coverage.as_deref(),
3658 min_invocations_hot,
3659 min_observation_volume,
3660 low_traffic_threshold,
3661 };
3662 dispatch_health(dispatch, &args)
3663}
3664
3665fn dispatch_setup_hooks_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3666 let Command::SetupHooks {
3667 agent,
3668 dry_run,
3669 force,
3670 user,
3671 gitignore_claude,
3672 uninstall,
3673 } = command
3674 else {
3675 unreachable!("setup-hooks dispatcher only handles setup-hooks commands");
3676 };
3677
3678 setup_hooks::run_setup_hooks(&setup_hooks::SetupHooksOptions {
3679 root: dispatch.root,
3680 agent: *agent,
3681 dry_run: *dry_run,
3682 force: *force,
3683 user: *user,
3684 gitignore_claude: *gitignore_claude,
3685 uninstall: *uninstall,
3686 })
3687}
3688
3689fn dispatch_audit_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3690 let Command::Audit {
3691 production_dead_code,
3692 production_health,
3693 production_dupes,
3694 dead_code_baseline,
3695 health_baseline,
3696 dupes_baseline,
3697 max_crap,
3698 coverage,
3699 coverage_root,
3700 no_css,
3701 css_deep,
3702 no_css_deep,
3703 gate,
3704 runtime_coverage,
3705 min_invocations_hot,
3706 gate_marker,
3707 brief,
3708 max_decisions,
3709 walkthrough_guide,
3710 walkthrough_file,
3711 walkthrough,
3712 mark_viewed,
3713 show_cleared,
3714 show_deprioritized,
3715 } = command
3716 else {
3717 unreachable!("audit dispatcher only handles audit commands");
3718 };
3719
3720 let brief = brief || walkthrough_guide || walkthrough || walkthrough_file.is_some();
3723
3724 dispatch_audit(
3725 dispatch,
3726 &AuditDispatchArgs {
3727 production_dead_code,
3728 production_health,
3729 production_dupes,
3730 dead_code_baseline,
3731 health_baseline,
3732 dupes_baseline,
3733 max_crap,
3734 coverage,
3735 coverage_root,
3736 no_css,
3737 css_deep,
3738 no_css_deep,
3739 gate,
3740 runtime_coverage,
3741 min_invocations_hot,
3742 gate_marker,
3743 brief,
3744 max_decisions,
3745 walkthrough_guide,
3746 walkthrough_file,
3747 walkthrough,
3748 mark_viewed,
3749 show_cleared,
3750 show_deprioritized,
3751 },
3752 )
3753}
3754
3755fn dispatch_audit_cache_command(
3756 dispatch: &DispatchContext<'_>,
3757 subcommand: &AuditCacheCli,
3758) -> ExitCode {
3759 match subcommand {
3760 AuditCacheCli::Remove { dry_run, yes } => {
3761 if !*dry_run && !*yes && !std::io::stdin().is_terminal() {
3762 return emit_error(
3763 "audit-cache remove requires --yes (or --force) in non-interactive environments. Use --dry-run to preview removal first, then pass --yes to confirm.",
3764 2,
3765 dispatch.output,
3766 );
3767 }
3768 match base_worktree::remove_reusable_audit_caches(dispatch.root, *dry_run) {
3769 Ok(report) => {
3770 let action = if *dry_run { "would remove" } else { "removed" };
3771 if matches!(dispatch.output, fallow_config::OutputFormat::Json) {
3772 let value = serde_json::json!({
3773 "kind": "audit-cache-remove",
3774 "schema_version": 1,
3775 "command": "audit-cache remove",
3776 "root": dispatch.root,
3777 "dry_run": report.dry_run,
3778 "found": report.found,
3779 "would_remove": report.found.saturating_sub(report.skipped),
3780 "removed": report.removed,
3781 "skipped": report.skipped,
3782 "complete": report.skipped == 0,
3783 });
3784 let output_code = report::emit_report_json(
3785 &value,
3786 "audit cache removal",
3787 dispatch.json_style,
3788 );
3789 if output_code != ExitCode::SUCCESS {
3790 return output_code;
3791 }
3792 } else if !dispatch.quiet {
3793 println!(
3794 "audit cache: {action} {}, skipped {} for {}",
3795 if *dry_run {
3796 report.found.saturating_sub(report.skipped)
3797 } else {
3798 report.removed
3799 },
3800 report.skipped,
3801 dispatch.root.display(),
3802 );
3803 }
3804 if report.skipped == 0 {
3805 ExitCode::SUCCESS
3806 } else {
3807 ExitCode::from(2)
3808 }
3809 }
3810 Err(error) => emit_error(
3811 &format!(
3812 "failed to remove audit caches for {}: {error}",
3813 dispatch.root.display()
3814 ),
3815 2,
3816 dispatch.output,
3817 ),
3818 }
3819 }
3820 }
3821}
3822
3823fn dispatch_flags_command(dispatch: &DispatchContext<'_>, top: Option<usize>) -> ExitCode {
3824 let cli = dispatch.cli;
3825 let root = dispatch.root;
3826 let output = dispatch.output;
3827 let quiet = dispatch.quiet;
3828 let threads = dispatch.threads;
3829 let production = match resolve_production_modes(cli, root, output, false, false, false) {
3830 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::DeadCode),
3831 Err(code) => return code,
3832 };
3833 flags::run_flags(&flags::FlagsOptions {
3834 root,
3835 config_path: &cli.config,
3836 output,
3837 json_style: dispatch.json_style,
3838 no_cache: cli.no_cache,
3839 threads,
3840 quiet,
3841 allow_remote_extends: cli.allow_remote_extends,
3842 production,
3843 workspace: cli.workspace.as_deref(),
3844 changed_workspaces: cli.changed_workspaces.as_deref(),
3845 changed_since: cli.changed_since.as_deref(),
3846 explain: cli.explain,
3847 top,
3848 })
3849}
3850
3851fn dispatch_suppressions_command(
3852 dispatch: &DispatchContext<'_>,
3853 file: &[std::path::PathBuf],
3854) -> ExitCode {
3855 let cli = dispatch.cli;
3856 let root = dispatch.root;
3857 let output = dispatch.output;
3858 let production = match resolve_production_modes(cli, root, output, false, false, false) {
3859 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::DeadCode),
3860 Err(code) => return code,
3861 };
3862 suppressions::run_suppressions(&suppressions::SuppressionsOptions {
3863 root,
3864 config_path: &cli.config,
3865 output,
3866 json_style: dispatch.json_style,
3867 no_cache: cli.no_cache,
3868 threads: dispatch.threads,
3869 quiet: dispatch.quiet,
3870 allow_remote_extends: cli.allow_remote_extends,
3871 production,
3872 workspace: cli.workspace.as_deref(),
3873 changed_workspaces: cli.changed_workspaces.as_deref(),
3874 changed_since: cli.changed_since.as_deref(),
3875 file,
3876 })
3877}
3878
3879fn dispatch_guard_command(dispatch: &DispatchContext<'_>, files: &[String]) -> ExitCode {
3880 guard::run_guard(&guard::GuardOptions {
3881 root: dispatch.root,
3882 config_path: &dispatch.cli.config,
3883 output: dispatch.output,
3884 json_style: dispatch.json_style,
3885 quiet: dispatch.quiet,
3886 allow_remote_extends: dispatch.cli.allow_remote_extends,
3887 files,
3888 })
3889}
3890
3891fn dispatch_rule_pack_command(dispatch: &DispatchContext<'_>, subcommand: RulePackCli) -> ExitCode {
3892 let ctx = rule_pack::RulePackContext {
3893 root: dispatch.root,
3894 config_path: &dispatch.cli.config,
3895 output: dispatch.output,
3896 json_style: dispatch.json_style,
3897 quiet: dispatch.quiet,
3898 no_cache: dispatch.cli.no_cache,
3899 threads: Some(dispatch.threads),
3900 allow_remote_extends: dispatch.cli.allow_remote_extends,
3901 };
3902 rule_pack::run(&map_rule_pack_subcommand(subcommand), &ctx)
3903}
3904
3905fn map_rule_pack_subcommand(subcommand: RulePackCli) -> rule_pack::RulePackSubcommand {
3906 match subcommand {
3907 RulePackCli::Init {
3908 name,
3909 template,
3910 dir,
3911 no_config,
3912 } => rule_pack::RulePackSubcommand::Init(rule_pack::InitArgs {
3913 name,
3914 template,
3915 dir,
3916 no_config,
3917 }),
3918 RulePackCli::List => rule_pack::RulePackSubcommand::List,
3919 RulePackCli::Test { pack } => {
3920 rule_pack::RulePackSubcommand::Test(rule_pack::TestArgs { pack })
3921 }
3922 RulePackCli::Schema => rule_pack::RulePackSubcommand::Schema,
3923 }
3924}
3925
3926fn map_license_subcommand(sub: LicenseCli) -> license::LicenseSubcommand {
3927 match sub {
3928 LicenseCli::Activate {
3929 jwt,
3930 from_file,
3931 stdin,
3932 trial,
3933 email,
3934 } => license::LicenseSubcommand::Activate(license::ActivateArgs {
3935 raw_jwt: jwt,
3936 from_file,
3937 from_stdin: stdin,
3938 trial,
3939 email,
3940 }),
3941 LicenseCli::Status => license::LicenseSubcommand::Status,
3942 LicenseCli::Refresh => license::LicenseSubcommand::Refresh,
3943 LicenseCli::Deactivate => license::LicenseSubcommand::Deactivate,
3944 }
3945}
3946
3947fn map_telemetry_subcommand(sub: TelemetryCli) -> telemetry::TelemetryCommand {
3948 match sub {
3949 TelemetryCli::Status => telemetry::TelemetryCommand::Status,
3950 TelemetryCli::Enable => telemetry::TelemetryCommand::Enable,
3951 TelemetryCli::Disable => telemetry::TelemetryCommand::Disable,
3952 TelemetryCli::Inspect { example } => telemetry::TelemetryCommand::Inspect { example },
3953 }
3954}
3955
3956fn map_ci_subcommand(sub: CiCli) -> ci::CiCommand {
3957 match sub {
3958 command @ CiCli::PlanPrComment { .. } => map_ci_plan_pr_comment(command),
3959 command @ CiCli::PostPrComment { .. } => map_ci_post_pr_comment(command),
3960 command @ CiCli::PostReview { .. } => map_ci_post_review(command),
3961 command @ CiCli::PostCheckRun { .. } => map_ci_post_check_run(command),
3962 command @ CiCli::ReconcileReview { .. } => map_ci_reconcile_review(command),
3963 }
3964}
3965
3966fn map_ci_plan_pr_comment(command: CiCli) -> ci::CiCommand {
3967 let CiCli::PlanPrComment {
3968 body,
3969 marker_id,
3970 clean,
3971 existing_comment_id,
3972 existing_body,
3973 } = command
3974 else {
3975 unreachable!("ci plan-pr-comment mapper called with different variant");
3976 };
3977
3978 ci::CiCommand::PlanPrComment {
3979 body,
3980 marker_id,
3981 clean,
3982 existing_comment_id,
3983 existing_body,
3984 }
3985}
3986
3987fn map_ci_post_pr_comment(command: CiCli) -> ci::CiCommand {
3988 let CiCli::PostPrComment {
3989 provider,
3990 pr,
3991 mr,
3992 body,
3993 envelope,
3994 marker_id,
3995 clean,
3996 repo,
3997 project_id,
3998 api_url,
3999 dry_run,
4000 } = command
4001 else {
4002 unreachable!("ci post-pr-comment mapper called with different variant");
4003 };
4004
4005 ci::CiCommand::PostPrComment {
4006 provider: map_ci_provider(provider),
4007 target: pr.or(mr),
4008 body,
4009 envelope,
4010 marker_id,
4011 clean,
4012 repo,
4013 project_id,
4014 api_url,
4015 dry_run,
4016 }
4017}
4018
4019fn map_ci_post_review(command: CiCli) -> ci::CiCommand {
4020 let CiCli::PostReview {
4021 provider,
4022 pr,
4023 mr,
4024 envelope,
4025 repo,
4026 project_id,
4027 api_url,
4028 dry_run,
4029 } = command
4030 else {
4031 unreachable!("ci post-review mapper called with different variant");
4032 };
4033
4034 ci::CiCommand::PostReview {
4035 provider: map_ci_provider(provider),
4036 target: pr.or(mr),
4037 envelope,
4038 repo,
4039 project_id,
4040 api_url,
4041 dry_run,
4042 }
4043}
4044
4045fn map_ci_post_check_run(command: CiCli) -> ci::CiCommand {
4046 let CiCli::PostCheckRun {
4047 provider,
4048 decision,
4049 repo,
4050 head_sha,
4051 api_url,
4052 split_gates,
4053 dry_run,
4054 } = command
4055 else {
4056 unreachable!("ci post-check-run mapper called with different variant");
4057 };
4058
4059 ci::CiCommand::PostCheckRun {
4060 provider: map_ci_provider(provider),
4061 decision,
4062 repo,
4063 head_sha,
4064 api_url,
4065 split_gates,
4066 dry_run,
4067 }
4068}
4069
4070fn map_ci_reconcile_review(command: CiCli) -> ci::CiCommand {
4071 let CiCli::ReconcileReview {
4072 provider,
4073 pr,
4074 mr,
4075 envelope,
4076 repo,
4077 project_id,
4078 api_url,
4079 dry_run,
4080 } = command
4081 else {
4082 unreachable!("ci reconcile-review mapper called with different variant");
4083 };
4084
4085 ci::CiCommand::ReconcileReview {
4086 provider: map_ci_provider(provider),
4087 target: pr.or(mr),
4088 envelope,
4089 repo,
4090 project_id,
4091 api_url,
4092 dry_run,
4093 }
4094}
4095
4096fn map_ci_provider(provider: CiProviderArg) -> ci::CiProvider {
4097 match provider {
4098 CiProviderArg::Github => ci::CiProvider::Github,
4099 CiProviderArg::Gitlab => ci::CiProvider::Gitlab,
4100 }
4101}
4102
4103fn map_coverage_subcommand(sub: &CoverageCli, explain: bool) -> coverage::CoverageSubcommand {
4104 match sub {
4105 CoverageCli::Setup {
4106 yes,
4107 non_interactive,
4108 json,
4109 } => map_coverage_setup(*yes, *non_interactive, *json, explain),
4110 CoverageCli::Analyze { .. } => map_coverage_analyze(sub),
4111 CoverageCli::UploadInventory { .. } => map_coverage_upload_inventory(sub),
4112 CoverageCli::UploadSourceMaps { .. } => map_coverage_upload_source_maps(sub),
4113 CoverageCli::UploadStaticFindings { .. } => map_coverage_upload_static_findings(sub),
4114 }
4115}
4116
4117fn map_coverage_setup(
4118 yes: bool,
4119 non_interactive: bool,
4120 json: bool,
4121 explain: bool,
4122) -> coverage::CoverageSubcommand {
4123 coverage::CoverageSubcommand::Setup(coverage::SetupArgs {
4124 yes,
4125 non_interactive: non_interactive || json,
4126 json,
4127 explain,
4128 })
4129}
4130
4131fn map_coverage_analyze(sub: &CoverageCli) -> coverage::CoverageSubcommand {
4132 let CoverageCli::Analyze {
4133 runtime_coverage,
4134 cloud,
4135 api_key,
4136 api_endpoint,
4137 repo,
4138 project_id,
4139 coverage_period,
4140 environment,
4141 commit_sha,
4142 production,
4143 min_invocations_hot,
4144 min_observation_volume,
4145 low_traffic_threshold,
4146 top,
4147 blast_radius,
4148 importance,
4149 } = sub
4150 else {
4151 unreachable!("coverage analyze mapper called with non-analyze variant");
4152 };
4153 coverage::CoverageSubcommand::Analyze(coverage::AnalyzeArgs {
4154 runtime_coverage: runtime_coverage.clone(),
4155 cloud: *cloud,
4156 api_key: api_key.clone(),
4157 api_endpoint: api_endpoint.clone(),
4158 repo: repo.clone(),
4159 project_id: project_id.clone(),
4160 coverage_period: *coverage_period,
4161 environment: environment.clone(),
4162 commit_sha: commit_sha.clone(),
4163 production: *production,
4164 min_invocations_hot: *min_invocations_hot,
4165 min_observation_volume: *min_observation_volume,
4166 low_traffic_threshold: *low_traffic_threshold,
4167 top: *top,
4168 blast_radius: *blast_radius,
4169 importance: *importance,
4170 })
4171}
4172
4173fn map_coverage_upload_inventory(sub: &CoverageCli) -> coverage::CoverageSubcommand {
4174 let CoverageCli::UploadInventory {
4175 api_key,
4176 api_endpoint,
4177 project_id,
4178 git_sha,
4179 allow_dirty,
4180 exclude_paths,
4181 path_prefix,
4182 dry_run,
4183 with_callers,
4184 ignore_upload_errors,
4185 } = sub
4186 else {
4187 unreachable!("coverage inventory mapper called with non-inventory variant");
4188 };
4189 coverage::CoverageSubcommand::UploadInventory(coverage::UploadInventoryArgs {
4190 api_key: api_key.clone(),
4191 api_endpoint: api_endpoint.clone(),
4192 project_id: project_id.clone(),
4193 git_sha: git_sha.clone(),
4194 allow_dirty: *allow_dirty,
4195 exclude_paths: exclude_paths.clone(),
4196 path_prefix: path_prefix.clone(),
4197 dry_run: *dry_run,
4198 with_callers: *with_callers,
4199 ignore_upload_errors: *ignore_upload_errors,
4200 })
4201}
4202
4203fn map_coverage_upload_source_maps(sub: &CoverageCli) -> coverage::CoverageSubcommand {
4204 let CoverageCli::UploadSourceMaps {
4205 dir,
4206 include,
4207 exclude,
4208 repo,
4209 git_sha,
4210 endpoint,
4211 strip_path,
4212 dry_run,
4213 concurrency,
4214 fail_fast,
4215 } = sub
4216 else {
4217 unreachable!("coverage source-map mapper called with non-source-map variant");
4218 };
4219 coverage::CoverageSubcommand::UploadSourceMaps(coverage::UploadSourceMapsArgs {
4220 dir: dir.clone(),
4221 include: include.clone(),
4222 exclude: exclude.clone(),
4223 repo: repo.clone(),
4224 git_sha: git_sha.clone(),
4225 endpoint: endpoint.clone(),
4226 strip_path: *strip_path,
4227 dry_run: *dry_run,
4228 concurrency: *concurrency,
4229 fail_fast: *fail_fast,
4230 })
4231}
4232
4233fn map_coverage_upload_static_findings(sub: &CoverageCli) -> coverage::CoverageSubcommand {
4234 let CoverageCli::UploadStaticFindings {
4235 api_key,
4236 api_endpoint,
4237 project_id,
4238 git_sha,
4239 allow_dirty,
4240 dry_run,
4241 ignore_upload_errors,
4242 } = sub
4243 else {
4244 unreachable!("coverage static-findings mapper called with non-static variant");
4245 };
4246 coverage::CoverageSubcommand::UploadStaticFindings(coverage::UploadStaticFindingsArgs {
4247 api_key: api_key.clone(),
4248 api_endpoint: api_endpoint.clone(),
4249 project_id: project_id.clone(),
4250 git_sha: git_sha.clone(),
4251 allow_dirty: *allow_dirty,
4252 dry_run: *dry_run,
4253 ignore_upload_errors: *ignore_upload_errors,
4254 })
4255}
4256
4257struct CheckDispatchArgs {
4258 filters: IssueFilters,
4259 trace_opts: TraceOptions,
4260 include_dupes: bool,
4261 top: Option<usize>,
4262 file: Vec<std::path::PathBuf>,
4263}
4264
4265#[derive(Clone, Copy)]
4266struct ListDispatchArgs {
4267 entry_points: bool,
4268 files: bool,
4269 plugins: bool,
4270 boundaries: bool,
4271 workspaces: bool,
4272}
4273
4274impl ListDispatchArgs {
4275 fn workspaces() -> Self {
4276 Self {
4277 entry_points: false,
4278 files: false,
4279 plugins: false,
4280 boundaries: false,
4281 workspaces: true,
4282 }
4283 }
4284}
4285
4286fn dispatch_viz(
4287 dispatch: &DispatchContext<'_>,
4288 output_path: Option<&std::path::Path>,
4289 no_open: bool,
4290 format: viz::VizFormat,
4291) -> ExitCode {
4292 let cli = dispatch.cli;
4293 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
4294 Ok(production) => production,
4295 Err(code) => return code,
4296 };
4297 viz::run_viz(&viz::VizOptions {
4298 root: dispatch.root,
4299 config_path: &cli.config,
4300 no_cache: cli.no_cache,
4301 threads: dispatch.threads,
4302 quiet: dispatch.quiet,
4303 production,
4304 allow_remote_extends: cli.allow_remote_extends,
4305 output_path,
4306 no_open,
4307 format,
4308 })
4309}
4310
4311fn dispatch_watch(dispatch: &DispatchContext<'_>, no_clear: bool) -> ExitCode {
4312 let cli = dispatch.cli;
4313 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
4314 Ok(production) => production,
4315 Err(code) => return code,
4316 };
4317 watch::run_watch(&watch::WatchOptions {
4318 root: dispatch.root,
4319 config_path: &cli.config,
4320 output: dispatch.output,
4321 json_style: dispatch.json_style,
4322 no_cache: cli.no_cache,
4323 threads: dispatch.threads,
4324 quiet: dispatch.quiet,
4325 allow_remote_extends: cli.allow_remote_extends,
4326 production,
4327 clear_screen: !no_clear,
4328 explain: cli.explain,
4329 include_entry_exports: cli.include_entry_exports,
4330 })
4331}
4332
4333#[derive(Clone, Copy)]
4334struct FixDispatchArgs {
4335 dry_run: bool,
4336 yes: bool,
4337 no_create_config: bool,
4338}
4339
4340fn dispatch_fix(dispatch: &DispatchContext<'_>, args: FixDispatchArgs) -> ExitCode {
4341 let cli = dispatch.cli;
4342 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
4343 Ok(production) => production,
4344 Err(code) => return code,
4345 };
4346 fix::run_fix(&fix::FixOptions {
4347 root: dispatch.root,
4348 config_path: &cli.config,
4349 output: dispatch.output,
4350 json_style: dispatch.json_style,
4351 no_cache: cli.no_cache,
4352 threads: dispatch.threads,
4353 quiet: dispatch.quiet,
4354 allow_remote_extends: cli.allow_remote_extends,
4355 dry_run: args.dry_run,
4356 yes: args.yes,
4357 production,
4358 no_create_config: args.no_create_config,
4359 })
4360}
4361
4362fn dispatch_list(dispatch: &DispatchContext<'_>, args: ListDispatchArgs) -> ExitCode {
4363 let cli = dispatch.cli;
4364 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
4365 Ok(production) => production,
4366 Err(code) => return code,
4367 };
4368 list::run_list(&ListOptions {
4369 root: dispatch.root,
4370 config_path: &cli.config,
4371 output: dispatch.output,
4372 json_style: dispatch.json_style,
4373 threads: dispatch.threads,
4374 no_cache: cli.no_cache,
4375 entry_points: args.entry_points,
4376 files: args.files,
4377 plugins: args.plugins,
4378 boundaries: args.boundaries,
4379 workspaces: args.workspaces,
4380 production,
4381 allow_remote_extends: cli.allow_remote_extends,
4382 })
4383}
4384
4385fn dispatch_check(dispatch: &DispatchContext<'_>, args: &CheckDispatchArgs) -> ExitCode {
4386 let cli = dispatch.cli;
4387 let (output, quiet, fail_on_issues) =
4388 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
4389 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
4390 Ok(production) => production,
4391 Err(code) => return code,
4392 };
4393 check::run_check(&CheckOptions {
4394 root: dispatch.root,
4395 config_path: &cli.config,
4396 output,
4397 json_style: dispatch.json_style,
4398 no_cache: cli.no_cache,
4399 threads: dispatch.threads,
4400 quiet,
4401 allow_remote_extends: cli.allow_remote_extends,
4402 fail_on_issues,
4403 filters: &args.filters,
4404 changed_since: cli.changed_since.as_deref(),
4405 diff_index: None,
4406 use_shared_diff_index: true,
4407 baseline: cli.baseline.as_deref(),
4408 save_baseline: cli.save_baseline.as_deref(),
4409 sarif_file: cli.sarif_file.as_deref(),
4410 production,
4411 production_override: Some(production),
4412 workspace: cli.workspace.as_deref(),
4413 changed_workspaces: cli.changed_workspaces.as_deref(),
4414 group_by: cli.group_by,
4415 include_dupes: args.include_dupes,
4416 trace_opts: &args.trace_opts,
4417 explain: cli.explain,
4418 top: args.top,
4419 file: &args.file,
4420 include_entry_exports: cli.include_entry_exports,
4421 summary: cli.summary,
4422 regression_opts: dispatch.regression_opts(
4423 cli.changed_since.is_some()
4424 || cli.workspace.is_some()
4425 || cli.changed_workspaces.is_some()
4426 || !args.file.is_empty(),
4427 ),
4428 retain_modules_for_health: false,
4429 defer_performance: false,
4430 })
4431}
4432
4433fn resolve_ignore_imports(ignore_imports: bool, no_ignore_imports: bool) -> Option<bool> {
4439 if no_ignore_imports {
4440 Some(false)
4441 } else if ignore_imports {
4442 Some(true)
4443 } else {
4444 None
4445 }
4446}
4447
4448struct DupesDispatchArgs {
4449 mode: Option<DupesMode>,
4450 min_tokens: Option<usize>,
4451 min_lines: Option<usize>,
4452 min_occurrences: Option<usize>,
4453 threshold: Option<f64>,
4454 skip_local: bool,
4455 cross_language: bool,
4456 ignore_imports: bool,
4457 no_ignore_imports: bool,
4458 top: Option<usize>,
4459 trace: Option<String>,
4460}
4461
4462fn dispatch_dupes(dispatch: &DispatchContext<'_>, args: &DupesDispatchArgs) -> ExitCode {
4463 let cli = dispatch.cli;
4464 let (output, quiet, _fail_on_issues) =
4465 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
4466 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::Dupes) {
4467 Ok(production) => production,
4468 Err(code) => return code,
4469 };
4470 dupes::run_dupes(&DupesOptions {
4471 root: dispatch.root,
4472 config_path: &cli.config,
4473 output,
4474 json_style: dispatch.json_style,
4475 no_cache: cli.no_cache,
4476 threads: dispatch.threads,
4477 quiet,
4478 allow_remote_extends: cli.allow_remote_extends,
4479 mode: args.mode,
4480 min_tokens: args.min_tokens,
4481 min_lines: args.min_lines,
4482 min_occurrences: args.min_occurrences,
4483 threshold: args.threshold,
4484 skip_local: args.skip_local,
4485 cross_language: args.cross_language,
4486 ignore_imports: resolve_ignore_imports(args.ignore_imports, args.no_ignore_imports),
4487 top: args.top,
4488 baseline_path: cli.baseline.as_deref(),
4489 save_baseline_path: cli.save_baseline.as_deref(),
4490 production,
4491 production_override: Some(production),
4492 trace: args.trace.as_deref(),
4493 changed_since: cli.changed_since.as_deref(),
4494 diff_index: None,
4495 use_shared_diff_index: true,
4496 changed_files: None,
4497 workspace: cli.workspace.as_deref(),
4498 changed_workspaces: cli.changed_workspaces.as_deref(),
4499 explain: cli.explain,
4500 explain_skipped: cli.explain_skipped,
4501 summary: cli.summary,
4502 group_by: cli.group_by,
4503 performance: cli.performance,
4504 })
4505}
4506
4507struct AuditDispatchArgs {
4508 production_dead_code: bool,
4509 production_health: bool,
4510 production_dupes: bool,
4511 dead_code_baseline: Option<PathBuf>,
4512 health_baseline: Option<PathBuf>,
4513 dupes_baseline: Option<PathBuf>,
4514 max_crap: Option<f64>,
4515 coverage: Option<PathBuf>,
4516 coverage_root: Option<PathBuf>,
4517 no_css: bool,
4518 css_deep: bool,
4519 no_css_deep: bool,
4520 gate: Option<AuditGateArg>,
4521 runtime_coverage: Option<PathBuf>,
4522 min_invocations_hot: u64,
4523 gate_marker: Option<String>,
4524 brief: bool,
4525 max_decisions: usize,
4526 walkthrough_guide: bool,
4528 walkthrough_file: Option<PathBuf>,
4531 walkthrough: bool,
4533 mark_viewed: Vec<PathBuf>,
4535 show_cleared: bool,
4537 show_deprioritized: bool,
4539}
4540
4541struct ResolvedAuditInputs {
4542 audit_cfg: fallow_config::AuditConfig,
4543 cache_dir: PathBuf,
4544 production: ProductionModes,
4545 dead_code_baseline: Option<PathBuf>,
4546 health_baseline: Option<PathBuf>,
4547 dupes_baseline: Option<PathBuf>,
4548 coverage: Option<PathBuf>,
4549}
4550
4551fn dispatch_audit(dispatch: &DispatchContext<'_>, args: &AuditDispatchArgs) -> ExitCode {
4552 let cli = dispatch.cli;
4553 let output = dispatch.output;
4554
4555 if cli.baseline.is_some() || cli.save_baseline.is_some() {
4556 return emit_error(
4557 "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>`)",
4558 2,
4559 output,
4560 );
4561 }
4562
4563 let inputs = match resolve_audit_inputs(dispatch, args) {
4564 Ok(inputs) => inputs,
4565 Err(code) => return code,
4566 };
4567
4568 run_resolved_audit(dispatch, args, &inputs)
4569}
4570
4571fn resolve_audit_inputs(
4572 dispatch: &DispatchContext<'_>,
4573 args: &AuditDispatchArgs,
4574) -> Result<ResolvedAuditInputs, ExitCode> {
4575 let cli = dispatch.cli;
4576 let root = dispatch.root;
4577 let output = dispatch.output;
4578 let config = load_config(
4579 root,
4580 &cli.config,
4581 LoadConfigArgs {
4582 output,
4583 no_cache: cli.no_cache,
4584 threads: dispatch.threads,
4585 production: cli.production,
4586 quiet: dispatch.quiet,
4587 allow_remote_extends: cli.allow_remote_extends,
4588 },
4589 )?;
4590 let cache_dir = config.cache_dir.clone();
4591 let audit_cfg = config.audit;
4592 let production = resolve_production_modes(
4593 cli,
4594 root,
4595 output,
4596 args.production_dead_code,
4597 args.production_health,
4598 args.production_dupes,
4599 )?;
4600 let resolved_dead_code_baseline = resolve_audit_baseline_path(
4601 root,
4602 args.dead_code_baseline.as_deref(),
4603 audit_cfg.dead_code_baseline.as_deref(),
4604 );
4605 let resolved_health_baseline = resolve_audit_baseline_path(
4606 root,
4607 args.health_baseline.as_deref(),
4608 audit_cfg.health_baseline.as_deref(),
4609 );
4610 let resolved_dupes_baseline = resolve_audit_baseline_path(
4611 root,
4612 args.dupes_baseline.as_deref(),
4613 audit_cfg.dupes_baseline.as_deref(),
4614 );
4615 let coverage = args
4616 .coverage
4617 .clone()
4618 .or_else(|| std::env::var("FALLOW_COVERAGE").ok().map(PathBuf::from));
4619
4620 Ok(ResolvedAuditInputs {
4621 audit_cfg,
4622 cache_dir,
4623 production,
4624 dead_code_baseline: resolved_dead_code_baseline,
4625 health_baseline: resolved_health_baseline,
4626 dupes_baseline: resolved_dupes_baseline,
4627 coverage,
4628 })
4629}
4630
4631fn audit_css_enabled(config: &fallow_config::AuditConfig, args: &AuditDispatchArgs) -> bool {
4632 !args.no_css && config.css.unwrap_or(true)
4633}
4634
4635fn audit_css_deep_enabled(config: &fallow_config::AuditConfig, args: &AuditDispatchArgs) -> bool {
4636 audit_css_enabled(config, args)
4637 && !args.no_css_deep
4638 && (args.css_deep || config.css_deep.unwrap_or(true))
4639}
4640
4641fn run_resolved_audit(
4642 dispatch: &DispatchContext<'_>,
4643 args: &AuditDispatchArgs,
4644 inputs: &ResolvedAuditInputs,
4645) -> ExitCode {
4646 let cli = dispatch.cli;
4647 audit::run_audit(
4648 &audit::AuditOptions {
4649 root: dispatch.root,
4650 config_path: &cli.config,
4651 cache_dir: &inputs.cache_dir,
4652 output: dispatch.output,
4653 json_style: dispatch.json_style,
4654 no_cache: cli.no_cache,
4655 threads: dispatch.threads,
4656 quiet: dispatch.quiet,
4657 allow_remote_extends: cli.allow_remote_extends,
4658 changed_since: cli.changed_since.as_deref(),
4659 production: cli.production,
4660 production_dead_code: Some(inputs.production.dead_code),
4661 production_health: Some(inputs.production.health),
4662 production_dupes: Some(inputs.production.dupes),
4663 workspace: cli.workspace.as_deref(),
4664 changed_workspaces: cli.changed_workspaces.as_deref(),
4665 explain: cli.explain,
4666 explain_skipped: cli.explain_skipped,
4667 performance: cli.performance,
4668 group_by: cli.group_by,
4669 dead_code_baseline: inputs.dead_code_baseline.as_deref(),
4670 health_baseline: inputs.health_baseline.as_deref(),
4671 dupes_baseline: inputs.dupes_baseline.as_deref(),
4672 max_crap: args.max_crap,
4673 coverage: inputs.coverage.as_deref(),
4674 coverage_root: args.coverage_root.as_deref(),
4675 gate: args.gate.map_or(inputs.audit_cfg.gate, Into::into),
4676 include_entry_exports: cli.include_entry_exports,
4677 css: audit_css_enabled(&inputs.audit_cfg, args),
4681 css_deep: audit_css_deep_enabled(&inputs.audit_cfg, args),
4682 runtime_coverage: args.runtime_coverage.as_deref(),
4683 min_invocations_hot: args.min_invocations_hot,
4684 brief: args.brief,
4685 max_decisions: args.max_decisions,
4686 walkthrough_guide: args.walkthrough_guide,
4687 walkthrough: args.walkthrough,
4688 mark_viewed: &args.mark_viewed,
4689 show_cleared: args.show_cleared,
4690 walkthrough_file: args.walkthrough_file.as_deref(),
4691 show_deprioritized: args.show_deprioritized,
4692 },
4693 args.gate_marker.as_deref(),
4694 )
4695}
4696
4697fn dispatch_decision_surface(dispatch: &DispatchContext<'_>, max_decisions: usize) -> ExitCode {
4701 let args = decision_surface_audit_args(max_decisions);
4702 let inputs = match resolve_audit_inputs(dispatch, &args) {
4703 Ok(inputs) => inputs,
4704 Err(code) => return code,
4705 };
4706 audit::run_decision_surface(&decision_surface_audit_options(
4707 dispatch,
4708 &inputs,
4709 max_decisions,
4710 ))
4711}
4712
4713fn decision_surface_audit_args(max_decisions: usize) -> AuditDispatchArgs {
4714 AuditDispatchArgs {
4715 production_dead_code: false,
4716 production_health: false,
4717 production_dupes: false,
4718 dead_code_baseline: None,
4719 health_baseline: None,
4720 dupes_baseline: None,
4721 max_crap: None,
4722 coverage: None,
4723 coverage_root: None,
4724 no_css: true,
4725 css_deep: false,
4726 no_css_deep: false,
4727 gate: None,
4728 runtime_coverage: None,
4729 min_invocations_hot: 0,
4730 gate_marker: None,
4731 brief: true,
4732 max_decisions,
4733 walkthrough_guide: false,
4734 walkthrough_file: None,
4735 walkthrough: false,
4736 mark_viewed: Vec::new(),
4737 show_cleared: false,
4738 show_deprioritized: false,
4739 }
4740}
4741
4742fn decision_surface_audit_options<'a>(
4743 dispatch: &'a DispatchContext<'a>,
4744 inputs: &'a ResolvedAuditInputs,
4745 max_decisions: usize,
4746) -> audit::AuditOptions<'a> {
4747 let cli = dispatch.cli;
4748 audit::AuditOptions {
4749 root: dispatch.root,
4750 config_path: &cli.config,
4751 cache_dir: &inputs.cache_dir,
4752 output: dispatch.output,
4753 json_style: dispatch.json_style,
4754 no_cache: cli.no_cache,
4755 threads: dispatch.threads,
4756 quiet: dispatch.quiet,
4757 allow_remote_extends: cli.allow_remote_extends,
4758 changed_since: cli.changed_since.as_deref(),
4759 production: cli.production,
4760 production_dead_code: Some(inputs.production.dead_code),
4761 production_health: Some(inputs.production.health),
4762 production_dupes: Some(inputs.production.dupes),
4763 workspace: cli.workspace.as_deref(),
4764 changed_workspaces: cli.changed_workspaces.as_deref(),
4765 explain: cli.explain,
4766 explain_skipped: cli.explain_skipped,
4767 performance: cli.performance,
4768 group_by: cli.group_by,
4769 dead_code_baseline: inputs.dead_code_baseline.as_deref(),
4770 health_baseline: inputs.health_baseline.as_deref(),
4771 dupes_baseline: inputs.dupes_baseline.as_deref(),
4772 max_crap: None,
4773 coverage: None,
4774 coverage_root: None,
4775 gate: inputs.audit_cfg.gate,
4776 include_entry_exports: cli.include_entry_exports,
4777 css: false,
4779 css_deep: false,
4780 runtime_coverage: None,
4781 min_invocations_hot: 0,
4782 brief: true,
4783 max_decisions,
4784 walkthrough_guide: false,
4785 walkthrough: false,
4786 mark_viewed: &[],
4787 show_cleared: false,
4788 walkthrough_file: None,
4789 show_deprioritized: false,
4790 }
4791}
4792
4793struct HealthDispatchArgs<'a> {
4794 max_cyclomatic: Option<u16>,
4795 max_cognitive: Option<u16>,
4796 max_crap: Option<f64>,
4797 top: Option<usize>,
4798 sort: health::SortBy,
4799 complexity: bool,
4800 complexity_breakdown: bool,
4801 file_scores: bool,
4802 coverage_gaps: bool,
4803 hotspots: bool,
4804 ownership: bool,
4805 ownership_emails: Option<fallow_config::EmailMode>,
4806 targets: bool,
4807 css: bool,
4808 effort: Option<EffortFilter>,
4809 score: bool,
4810 min_score: Option<f64>,
4811 min_severity: Option<fallow_output::FindingSeverity>,
4812 report_only: bool,
4813 since: Option<&'a str>,
4814 min_commits: Option<u32>,
4815 save_snapshot: Option<&'a Option<String>>,
4816 trend: bool,
4817 coverage: Option<&'a std::path::Path>,
4818 coverage_root: Option<&'a std::path::Path>,
4819 runtime_coverage: Option<&'a std::path::Path>,
4820 min_invocations_hot: u64,
4821 min_observation_volume: Option<u32>,
4822 low_traffic_threshold: Option<f64>,
4823}
4824
4825struct ResolvedHealthCoverageInputs {
4826 coverage: Option<PathBuf>,
4827 coverage_root: Option<PathBuf>,
4828}
4829
4830fn resolve_health_coverage_inputs(
4831 dispatch: &DispatchContext<'_>,
4832 cli_coverage: Option<&std::path::Path>,
4833 cli_coverage_root: Option<&std::path::Path>,
4834) -> Result<ResolvedHealthCoverageInputs, ExitCode> {
4835 let env_coverage = path_from_env("FALLOW_COVERAGE");
4836 let env_coverage_root = path_from_env("FALLOW_COVERAGE_ROOT");
4837 let needs_config_coverage = cli_coverage.is_none() && env_coverage.is_none();
4838 let needs_config_coverage_root = cli_coverage_root.is_none() && env_coverage_root.is_none();
4839 let config_health = if needs_config_coverage || needs_config_coverage_root {
4840 Some(
4841 load_config(
4842 dispatch.root,
4843 &dispatch.cli.config,
4844 LoadConfigArgs {
4845 output: dispatch.output,
4846 no_cache: dispatch.cli.no_cache,
4847 threads: dispatch.threads,
4848 production: dispatch.cli.production,
4849 quiet: dispatch.quiet,
4850 allow_remote_extends: dispatch.cli.allow_remote_extends,
4851 },
4852 )?
4853 .health,
4854 )
4855 } else {
4856 None
4857 };
4858
4859 Ok(ResolvedHealthCoverageInputs {
4860 coverage: cli_coverage
4861 .map(std::path::Path::to_path_buf)
4862 .or(env_coverage)
4863 .or_else(|| {
4864 config_health
4865 .as_ref()
4866 .and_then(|health| health.coverage.clone())
4867 }),
4868 coverage_root: cli_coverage_root
4869 .map(std::path::Path::to_path_buf)
4870 .or(env_coverage_root)
4871 .or_else(|| {
4872 config_health
4873 .as_ref()
4874 .and_then(|health| health.coverage_root.clone())
4875 }),
4876 })
4877}
4878
4879fn path_from_env(name: &str) -> Option<PathBuf> {
4880 std::env::var_os(name)
4881 .filter(|value| !value.is_empty())
4882 .map(PathBuf::from)
4883}
4884
4885fn validate_health_report_only_gate(
4886 report_only: bool,
4887 min_score: Option<f64>,
4888 min_severity: Option<fallow_output::FindingSeverity>,
4889 output: fallow_config::OutputFormat,
4890) -> Result<(), ExitCode> {
4891 if report_only && (min_score.is_some() || min_severity.is_some()) {
4892 return Err(emit_error(
4893 "--report-only cannot be combined with --min-score or --min-severity. \
4894 --report-only always exits 0; drop it to gate on score/severity, or \
4895 drop the gate flags to stay advisory.",
4896 2,
4897 output,
4898 ));
4899 }
4900
4901 Ok(())
4902}
4903
4904fn resolve_runtime_coverage_options(
4905 runtime_coverage: Option<&std::path::Path>,
4906 min_invocations_hot: u64,
4907 min_observation_volume: Option<u32>,
4908 low_traffic_threshold: Option<f64>,
4909 output: fallow_config::OutputFormat,
4910) -> Result<Option<fallow_engine::health::RuntimeCoverageOptions>, ExitCode> {
4911 let Some(path) = runtime_coverage else {
4912 return Ok(None);
4913 };
4914
4915 health::coverage::prepare_options(
4916 path,
4917 min_invocations_hot,
4918 min_observation_volume,
4919 low_traffic_threshold,
4920 output,
4921 )
4922 .map(Some)
4923}
4924
4925fn dispatch_health(dispatch: &DispatchContext<'_>, args: &HealthDispatchArgs<'_>) -> ExitCode {
4926 let cli = dispatch.cli;
4927 let root = dispatch.root;
4928 let (output, _quiet, _fail_on_issues) =
4929 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
4930 if let Err(code) = validate_health_report_only_gate(
4931 args.report_only,
4932 args.min_score,
4933 args.min_severity,
4934 output,
4935 ) {
4936 return code;
4937 }
4938 let runtime_coverage = match resolve_runtime_coverage_options(
4939 args.runtime_coverage,
4940 args.min_invocations_hot,
4941 args.min_observation_volume,
4942 args.low_traffic_threshold,
4943 output,
4944 ) {
4945 Ok(options) => options,
4946 Err(code) => return code,
4947 };
4948 let production = match resolve_production_modes(cli, root, output, false, false, false) {
4949 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::Health),
4950 Err(code) => return code,
4951 };
4952 let coverage_inputs =
4953 match resolve_health_coverage_inputs(dispatch, args.coverage, args.coverage_root) {
4954 Ok(inputs) => inputs,
4955 Err(code) => return code,
4956 };
4957 let run = derive_health_dispatch_run(args, output, &coverage_inputs, runtime_coverage);
4958 run_health_dispatch(dispatch, args, ResolvedHealthDispatch { run, production })
4959}
4960
4961fn derive_health_dispatch_run<'a>(
4962 args: &'a HealthDispatchArgs<'a>,
4963 output: fallow_config::OutputFormat,
4964 coverage_inputs: &'a ResolvedHealthCoverageInputs,
4965 runtime_coverage: Option<fallow_engine::health::RuntimeCoverageOptions>,
4966) -> fallow_engine::health::HealthRunOptions<'a> {
4967 fallow_engine::health::derive_health_run_options(fallow_engine::health::HealthRunOptionsInput {
4968 output,
4969 thresholds: health_threshold_overrides(args),
4970 top: args.top,
4971 sort: args.sort.clone().into(),
4972 complexity: args.complexity,
4973 file_scores: args.file_scores,
4974 coverage_gaps: args.coverage_gaps,
4975 hotspots: args.hotspots,
4976 ownership: args.ownership,
4977 ownership_emails: args.ownership_emails,
4978 targets: args.targets,
4979 css: args.css,
4980 effort: args.effort.map(EffortFilter::to_estimate),
4981 score: args.score,
4982 gates: health_gate_options(args),
4983 snapshot_requested: args.save_snapshot.is_some(),
4984 trend: args.trend,
4985 since: args.since,
4986 min_commits: args.min_commits,
4987 coverage_inputs: health_coverage_inputs(coverage_inputs),
4988 runtime_coverage,
4989 })
4990}
4991
4992fn health_threshold_overrides(
4993 args: &HealthDispatchArgs<'_>,
4994) -> fallow_engine::health::HealthThresholdOverrides {
4995 fallow_engine::health::HealthThresholdOverrides {
4996 max_cyclomatic: args.max_cyclomatic,
4997 max_cognitive: args.max_cognitive,
4998 max_crap: args.max_crap,
4999 }
5000}
5001
5002fn health_gate_options(args: &HealthDispatchArgs<'_>) -> fallow_engine::health::HealthGateOptions {
5003 fallow_engine::health::HealthGateOptions {
5004 min_score: args.min_score,
5005 min_severity: args.min_severity,
5006 report_only: args.report_only,
5007 }
5008}
5009
5010fn health_coverage_inputs(
5011 coverage_inputs: &ResolvedHealthCoverageInputs,
5012) -> fallow_engine::health::HealthCoverageInputs<'_> {
5013 fallow_engine::health::HealthCoverageInputs {
5014 coverage: coverage_inputs.coverage.as_deref(),
5015 coverage_root: coverage_inputs.coverage_root.as_deref(),
5016 }
5017}
5018
5019struct ResolvedHealthDispatch<'a> {
5023 run: fallow_engine::health::HealthRunOptions<'a>,
5024 production: bool,
5025}
5026
5027fn run_health_dispatch(
5030 dispatch: &DispatchContext<'_>,
5031 args: &HealthDispatchArgs<'_>,
5032 resolved: ResolvedHealthDispatch<'_>,
5033) -> ExitCode {
5034 let cli = dispatch.cli;
5035 let (output, quiet, _fail_on_issues) =
5036 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
5037 let run = resolved.run;
5038 let sections = run.sections;
5039 let production = resolved.production;
5040 health::run_health(
5041 &HealthOptions {
5042 root: dispatch.root,
5043 config_path: &cli.config,
5044 output,
5045 no_cache: cli.no_cache,
5046 threads: dispatch.threads,
5047 quiet,
5048 thresholds: run.thresholds,
5049 top: run.top,
5050 sort: run.sort,
5051 production,
5052 production_override: Some(production),
5053 allow_remote_extends: cli.allow_remote_extends,
5054 changed_since: cli.changed_since.as_deref(),
5055 diff_index: None,
5056 use_shared_diff_index: true,
5057 workspace: cli.workspace.as_deref(),
5058 changed_workspaces: cli.changed_workspaces.as_deref(),
5059 baseline: cli.baseline.as_deref(),
5060 save_baseline: cli.save_baseline.as_deref(),
5061 complexity: sections.complexity,
5062 file_scores: sections.file_scores,
5063 coverage_gaps: sections.coverage_gaps,
5064 config_activates_coverage_gaps: !sections.any_section,
5065 hotspots: sections.hotspots,
5066 ownership: run.ownership,
5067 ownership_emails: run.ownership_emails,
5068 targets: sections.targets,
5069 css: sections.css,
5070 css_deep: false,
5071 force_full: sections.force_full,
5072 score_only_output: sections.score_only_output,
5073 enforce_coverage_gap_gate: true,
5074 effort: run.effort,
5075 score: sections.score,
5076 gates: run.gates,
5077 since: run.since,
5078 min_commits: run.min_commits,
5079 explain: cli.explain,
5080 summary: cli.summary,
5081 save_snapshot: args
5082 .save_snapshot
5083 .map(|opt| PathBuf::from(opt.as_deref().unwrap_or_default())),
5084 trend: args.trend,
5085 coverage_inputs: run.coverage_inputs,
5086 performance: cli.performance,
5087 runtime_coverage: run.runtime_coverage,
5088 churn_file: cli.churn_file.as_deref(),
5089 complexity_breakdown: args.complexity_breakdown,
5090 group_by: cli.group_by.map(Into::into),
5091 },
5092 dispatch.json_style,
5093 )
5094}
5095
5096#[cfg(test)]
5097mod tests {
5098 use super::*;
5099
5100 #[test]
5104 fn cli_definition_has_no_flag_collisions() {
5105 use clap::CommandFactory;
5106 Cli::command().debug_assert();
5107 }
5108
5109 #[test]
5110 fn regression_baseline_help_explains_the_default_destination() {
5111 use clap::CommandFactory;
5112 let help = Cli::command().render_long_help().to_string();
5113
5114 assert!(help.contains("Omit PATH to update regression.baseline"));
5115 assert!(help.contains("discovered fallow config"));
5116 assert!(help.contains("create .fallowrc.json when none exists"));
5117 }
5118
5119 #[test]
5123 fn after_help_lists_every_task_matrix_command() {
5124 for row in crate::task_matrix::TASK_MATRIX {
5125 assert!(
5126 TOP_LEVEL_AFTER_HELP.contains(row.command),
5127 "root --help cheat sheet is missing task-matrix command '{}'; \
5128 update TOP_LEVEL_AFTER_HELP to match TASK_MATRIX",
5129 row.command
5130 );
5131 }
5132 }
5133
5134 #[test]
5138 fn high_value_commands_route_to_distinct_workflows() {
5139 use clap::Parser;
5140 use fallow_config::OutputFormat;
5141
5142 let distinct = [
5143 (vec!["fallow", "impact"], telemetry::Workflow::Impact),
5144 (vec!["fallow", "security"], telemetry::Workflow::Security),
5145 (vec!["fallow", "fix"], telemetry::Workflow::Fix),
5146 (
5147 vec!["fallow", "explain", "unused-exports"],
5148 telemetry::Workflow::Explain,
5149 ),
5150 (
5151 vec!["fallow", "watch"],
5152 telemetry::Workflow::CodeQualityReview,
5153 ),
5154 (
5155 vec!["fallow", "list"],
5156 telemetry::Workflow::ProjectInventory,
5157 ),
5158 (
5159 vec!["fallow", "workspaces"],
5160 telemetry::Workflow::ProjectInventory,
5161 ),
5162 (
5163 vec!["fallow", "schema"],
5164 telemetry::Workflow::ProjectInventory,
5165 ),
5166 (vec!["fallow", "init"], telemetry::Workflow::Setup),
5167 (
5168 vec!["fallow", "hooks", "install", "--target", "git"],
5169 telemetry::Workflow::Setup,
5170 ),
5171 (vec!["fallow", "config-schema"], telemetry::Workflow::Setup),
5172 (vec!["fallow", "plugin-schema"], telemetry::Workflow::Setup),
5173 (
5174 vec!["fallow", "rule-pack-schema"],
5175 telemetry::Workflow::Setup,
5176 ),
5177 (vec!["fallow", "config"], telemetry::Workflow::Setup),
5178 (
5179 vec!["fallow", "ci-template", "gitlab"],
5180 telemetry::Workflow::Setup,
5181 ),
5182 (vec!["fallow", "migrate"], telemetry::Workflow::Setup),
5183 (
5184 vec!["fallow", "telemetry", "status"],
5185 telemetry::Workflow::Setup,
5186 ),
5187 (vec!["fallow", "setup-hooks"], telemetry::Workflow::Setup),
5188 (
5189 vec!["fallow", "audit-cache", "remove", "--root", "."],
5190 telemetry::Workflow::Setup,
5191 ),
5192 (
5193 vec!["fallow", "license", "status"],
5194 telemetry::Workflow::License,
5195 ),
5196 ];
5197 for (argv, expected) in distinct {
5198 let cli = Cli::try_parse_from(&argv).expect("argv parses");
5199 assert_eq!(
5200 telemetry_workflow_for_command(cli.command.as_ref(), OutputFormat::Json),
5201 expected,
5202 "{argv:?} should map to {expected:?}"
5203 );
5204 }
5205 }
5206
5207 #[test]
5212 fn version_flag_accepts_lower_v_upper_v_and_long() {
5213 use clap::CommandFactory;
5214 for argv in [["fallow", "-v"], ["fallow", "-V"], ["fallow", "--version"]] {
5215 let err = Cli::command()
5216 .try_get_matches_from(argv)
5217 .expect_err("version flag should short-circuit parsing");
5218 assert_eq!(
5219 err.kind(),
5220 clap::error::ErrorKind::DisplayVersion,
5221 "{argv:?} should trigger the Version action"
5222 );
5223 }
5224 }
5225
5226 #[test]
5231 fn cli_help_text_contains_no_implementation_status_wording() {
5232 use clap::CommandFactory;
5233 let mut root = Cli::command();
5234 let mut violations: Vec<(String, String)> = Vec::new();
5235 visit_help(&mut root, "fallow", &mut violations);
5236 assert!(
5237 violations.is_empty(),
5238 "found implementation-status wording in --help output:\n{}",
5239 violations
5240 .iter()
5241 .map(|(cmd, line)| format!(" {cmd}: {line}"))
5242 .collect::<Vec<_>>()
5243 .join("\n")
5244 );
5245 }
5246
5247 #[test]
5248 fn top_level_help_groups_commands_by_workflow() {
5249 use clap::CommandFactory;
5250 let help = Cli::command().render_long_help().to_string();
5251 let expected_order = [
5252 "Analysis:",
5253 " dead-code",
5254 " dupes",
5255 " health",
5256 " flags",
5257 " security",
5258 " audit",
5259 "Workflow:",
5260 " watch",
5261 " fix",
5262 "Project inspection:",
5263 " list",
5264 " workspaces",
5265 " explain",
5266 " impact",
5267 " viz",
5268 "Setup and configuration:",
5269 " init",
5270 " recommend",
5271 " migrate",
5272 " config",
5273 " config-schema",
5274 " plugin-schema",
5275 " plugin-check",
5276 " rule-pack-schema",
5277 "Automation and CI:",
5278 " ci",
5279 " ci-template",
5280 " hooks",
5281 " setup-hooks",
5282 "Runtime coverage:",
5283 " coverage",
5284 " license",
5285 "Reference:",
5286 " schema",
5287 " help",
5288 "Options:",
5289 ];
5290 let mut cursor = 0;
5291 for needle in expected_order {
5292 let Some(offset) = help[cursor..].find(needle) else {
5293 panic!("top-level help missing `{needle}` after byte {cursor}:\n{help}");
5294 };
5295 cursor += offset + needle.len();
5296 }
5297 }
5298
5299 #[test]
5300 fn security_help_hides_globals_rejected_by_security_validator() {
5301 let help = render_security_help(SecurityHelpTarget::Parent);
5302
5303 for long in SECURITY_UNSUPPORTED_GLOBAL_LONGS {
5304 assert!(
5305 !help_contains_long_flag(&help, long),
5306 "security help must hide unsupported --{long}:\n{help}"
5307 );
5308 }
5309
5310 for long in [
5311 "root",
5312 "config",
5313 "format",
5314 "quiet",
5315 "no-cache",
5316 "threads",
5317 "changed-since",
5318 "diff-file",
5319 "diff-stdin",
5320 "workspace",
5321 "changed-workspaces",
5322 "ci",
5323 "fail-on-issues",
5324 "sarif-file",
5325 "summary",
5326 "output-file",
5327 "max-file-size",
5328 "explain",
5329 "surface",
5330 ] {
5331 assert!(
5332 help_contains_long_flag(&help, long),
5333 "security help must keep supported --{long}:\n{help}"
5334 );
5335 }
5336 }
5337
5338 #[test]
5339 fn security_help_detection_covers_subcommand_and_help_alias_forms() {
5340 assert_eq!(
5341 security_help_target(["security", "--help"]),
5342 Some(SecurityHelpTarget::Parent)
5343 );
5344 assert_eq!(
5345 security_help_target(["security", "-h"]),
5346 Some(SecurityHelpTarget::Parent)
5347 );
5348 assert_eq!(
5349 security_help_target(["--format", "json", "security", "--help"]),
5350 Some(SecurityHelpTarget::Parent)
5351 );
5352 assert_eq!(
5353 security_help_target(["help", "security"]),
5354 Some(SecurityHelpTarget::Parent)
5355 );
5356 assert_eq!(
5357 security_help_target(["security", "survivors", "--help"]),
5358 Some(SecurityHelpTarget::Survivors)
5359 );
5360 assert_eq!(
5361 security_help_target(["security", "survivors", "-h"]),
5362 Some(SecurityHelpTarget::Survivors)
5363 );
5364 assert_eq!(
5365 security_help_target(["help", "security", "survivors"]),
5366 Some(SecurityHelpTarget::Survivors)
5367 );
5368 assert_eq!(
5369 security_help_target(["security", "blind-spots", "--help"]),
5370 Some(SecurityHelpTarget::BlindSpots)
5371 );
5372 assert_eq!(
5373 security_help_target(["help", "security", "blind-spots"]),
5374 Some(SecurityHelpTarget::BlindSpots)
5375 );
5376 assert_eq!(security_help_target(["health", "--help"]), None);
5377 assert_eq!(security_help_target(["help", "health"]), None);
5378 }
5379
5380 #[test]
5381 fn security_unsupported_global_validator_matches_hidden_help_contract() {
5382 for (argv, expected) in [
5383 (vec!["fallow", "security", "--performance"], "--performance"),
5384 (
5385 vec!["fallow", "security", "--baseline", "base.json"],
5386 "--baseline",
5387 ),
5388 (
5389 vec!["fallow", "security", "--dupes-mode", "weak"],
5390 "--dupes-mode",
5391 ),
5392 ] {
5393 let cli = Cli::try_parse_from(argv).expect("security global parses before validation");
5394 assert_eq!(unsupported_security_global(&cli), Some(expected));
5395 }
5396
5397 let explain = Cli::try_parse_from(["fallow", "security", "--explain"])
5398 .expect("security --explain parses");
5399 assert_eq!(unsupported_security_global(&explain), None);
5400 }
5401
5402 #[test]
5403 fn programmatic_common_options_track_analysis_affecting_cli_globals() {
5404 use clap::CommandFactory;
5405
5406 let cli_flags: std::collections::BTreeSet<String> = Cli::command()
5407 .get_arguments()
5408 .filter(|arg| arg.is_global_set())
5409 .filter_map(|arg| arg.get_long().map(str::to_owned))
5410 .filter(|name| {
5411 matches!(
5412 name.as_str(),
5413 "root"
5414 | "config"
5415 | "allow-remote-extends"
5416 | "no-cache"
5417 | "threads"
5418 | "changed-since"
5419 | "diff-file"
5420 | "production"
5421 | "workspace"
5422 | "changed-workspaces"
5423 | "explain"
5424 )
5425 })
5426 .collect();
5427 let programmatic_flags: std::collections::BTreeSet<String> =
5428 fallow_api::COMMON_ANALYSIS_OPTION_FLAGS
5429 .iter()
5430 .map(|flag| (*flag).to_owned())
5431 .collect();
5432
5433 assert_eq!(programmatic_flags, cli_flags);
5434 }
5435
5436 #[test]
5437 fn dead_code_registry_filter_flags_are_exposed_by_clap() {
5438 use clap::CommandFactory;
5439
5440 let cli = Cli::command();
5441 let dead_code = cli
5442 .get_subcommands()
5443 .find(|command| command.get_name() == "dead-code")
5444 .expect("dead-code subcommand is registered");
5445 let cli_flags: std::collections::BTreeSet<String> = dead_code
5446 .get_arguments()
5447 .filter_map(|arg| arg.get_long().map(|long| format!("--{long}")))
5448 .collect();
5449
5450 for flag in fallow_types::issue_meta::DEAD_CODE_FILTER_FLAGS.iter() {
5451 assert!(
5452 cli_flags.contains(*flag),
5453 "registry filter flag {flag} is missing from dead-code clap args"
5454 );
5455 }
5456 }
5457
5458 fn help_contains_long_flag(help: &str, long: &str) -> bool {
5459 let flag = format!("--{long}");
5460 help.split(|c: char| c.is_whitespace() || c == ',' || c == '[' || c == ']')
5461 .any(|token| token == flag)
5462 }
5463
5464 fn visit_help(cmd: &mut clap::Command, path: &str, violations: &mut Vec<(String, String)>) {
5465 let help = cmd.render_long_help().to_string();
5466 for line in scan_forbidden(&help) {
5467 violations.push((path.to_owned(), line));
5468 }
5469 let names: Vec<String> = cmd
5470 .get_subcommands()
5471 .map(|sub| sub.get_name().to_owned())
5472 .collect();
5473 for name in names {
5474 if name == "help" {
5475 continue;
5476 }
5477 if let Some(sub) = cmd.find_subcommand_mut(&name) {
5478 let sub_path = format!("{path} {name}");
5479 visit_help(sub, &sub_path, violations);
5480 }
5481 }
5482 }
5483
5484 fn scan_forbidden(s: &str) -> Vec<String> {
5485 let lower = s.to_ascii_lowercase();
5486 let mut out = Vec::new();
5487 for word in ["stub", "placeholder"] {
5488 if let Some(idx) = find_whole_word(&lower, word) {
5489 out.push(extract_line(s, idx));
5490 }
5491 }
5492 if let Some(idx) = lower.find("not yet") {
5493 out.push(extract_line(s, idx));
5494 }
5495 out
5496 }
5497
5498 fn find_whole_word(haystack: &str, word: &str) -> Option<usize> {
5499 let bytes = haystack.as_bytes();
5500 let mut start = 0;
5501 while let Some(rel) = haystack[start..].find(word) {
5502 let abs = start + rel;
5503 let before_ok = abs == 0 || !bytes[abs - 1].is_ascii_alphanumeric();
5504 let after_idx = abs + word.len();
5505 let after_ok = after_idx >= bytes.len() || !bytes[after_idx].is_ascii_alphanumeric();
5506 if before_ok && after_ok {
5507 return Some(abs);
5508 }
5509 start = abs + word.len();
5510 }
5511 None
5512 }
5513
5514 fn extract_line(s: &str, byte_idx: usize) -> String {
5515 let line_start = s[..byte_idx].rfind('\n').map_or(0, |i| i + 1);
5516 let line_end = s[byte_idx..].find('\n').map_or(s.len(), |i| byte_idx + i);
5517 s[line_start..line_end].trim().to_owned()
5518 }
5519
5520 #[test]
5521 fn emit_error_returns_given_exit_code() {
5522 let code = emit_error("test error", 2, fallow_config::OutputFormat::Human);
5523 assert_eq!(code, ExitCode::from(2));
5524 }
5525
5526 fn telemetry_run_for_mode(mode: telemetry::AnalysisMode) -> TelemetryRun {
5527 TelemetryRun {
5528 workflow: telemetry::Workflow::Health,
5529 output: fallow_config::OutputFormat::Json,
5530 quiet: true,
5531 start: std::time::Instant::now(),
5532 context: telemetry::WorkflowContext {
5533 run_scope: telemetry::RunScope::FullProject,
5534 config_shape: telemetry::ConfigShape::Default,
5535 output_destination: telemetry::OutputDestination::Stdout,
5536 analysis_mode: mode,
5537 },
5538 }
5539 }
5540
5541 #[test]
5542 fn fallback_failure_reason_skips_success_and_findings() {
5543 let run = telemetry_run_for_mode(telemetry::AnalysisMode::Static);
5544
5545 assert_eq!(fallback_failure_reason_for(&run, ExitCode::SUCCESS), None);
5546 assert_eq!(fallback_failure_reason_for(&run, ExitCode::from(1)), None);
5547 }
5548
5549 #[test]
5550 fn fallback_failure_reason_classifies_network_auth_and_analysis() {
5551 let static_run = telemetry_run_for_mode(telemetry::AnalysisMode::Static);
5552 let cloud_run = telemetry_run_for_mode(telemetry::AnalysisMode::ProductionCoverage);
5553
5554 assert_eq!(
5555 fallback_failure_reason_for(&static_run, ExitCode::from(api::NETWORK_EXIT_CODE)),
5556 Some(telemetry::FailureReason::Network),
5557 );
5558 assert_eq!(
5559 fallback_failure_reason_for(&static_run, ExitCode::from(12)),
5560 Some(telemetry::FailureReason::Auth),
5561 );
5562 assert_eq!(
5563 fallback_failure_reason_for(&cloud_run, ExitCode::from(3)),
5564 Some(telemetry::FailureReason::Auth),
5565 );
5566 assert_eq!(
5567 fallback_failure_reason_for(&static_run, ExitCode::from(2)),
5568 Some(telemetry::FailureReason::Analysis),
5569 );
5570 }
5571
5572 #[test]
5573 fn bare_coverage_flags_parse_without_subcommand() {
5574 let cli = Cli::try_parse_from([
5575 "fallow",
5576 "--coverage",
5577 "coverage/coverage-final.json",
5578 "--coverage-root",
5579 "/ci/workspace",
5580 ])
5581 .expect("bare combined coverage flags should parse");
5582 assert!(cli.command.is_none());
5583 assert_eq!(
5584 cli.coverage.as_deref(),
5585 Some(std::path::Path::new("coverage/coverage-final.json"))
5586 );
5587 assert_eq!(
5588 cli.coverage_root.as_deref(),
5589 Some(std::path::Path::new("/ci/workspace"))
5590 );
5591 }
5592
5593 #[test]
5594 fn bare_coverage_before_subcommand_is_detectable() {
5595 let cli = Cli::try_parse_from([
5596 "fallow",
5597 "--coverage",
5598 "coverage/coverage-final.json",
5599 "dead-code",
5600 ])
5601 .expect("clap should parse pre-subcommand bare coverage for custom rejection");
5602 assert!(cli.command.is_some());
5603 assert!(cli_has_bare_coverage_input(&cli));
5604 let message = bare_coverage_subcommand_error_message();
5605 assert!(message.contains("bare combined-mode flags"));
5606 assert!(message.contains("fallow health --coverage <coverage-final.json>"));
5607 }
5608
5609 #[test]
5610 fn subcommand_coverage_flag_keeps_regular_clap_error() {
5611 let Err(err) = Cli::try_parse_from(["fallow", "dead-code", "--coverage"]) else {
5612 panic!("dead-code --coverage should fail to parse");
5613 };
5614 assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
5615 }
5616
5617 #[test]
5618 fn format_parsing_covers_all_variants() {
5619 assert!(matches!(parse_format_arg("json"), Some(Format::Json)));
5620 assert!(matches!(parse_format_arg("JSON"), Some(Format::Json)));
5621 assert!(matches!(parse_format_arg("human"), Some(Format::Human)));
5622 assert!(matches!(parse_format_arg("sarif"), Some(Format::Sarif)));
5623 assert!(matches!(parse_format_arg("compact"), Some(Format::Compact)));
5624 assert!(matches!(
5625 parse_format_arg("markdown"),
5626 Some(Format::Markdown)
5627 ));
5628 assert!(matches!(parse_format_arg("md"), Some(Format::Markdown)));
5629 assert!(matches!(
5630 parse_format_arg("codeclimate"),
5631 Some(Format::CodeClimate)
5632 ));
5633 assert!(matches!(
5634 parse_format_arg("gitlab-codequality"),
5635 Some(Format::CodeClimate)
5636 ));
5637 assert!(matches!(
5638 parse_format_arg("gitlab-code-quality"),
5639 Some(Format::CodeClimate)
5640 ));
5641 assert!(matches!(
5642 parse_format_arg("pr-comment-github"),
5643 Some(Format::PrCommentGithub)
5644 ));
5645 assert!(matches!(
5646 parse_format_arg("pr-comment-gitlab"),
5647 Some(Format::PrCommentGitlab)
5648 ));
5649 assert!(matches!(
5650 parse_format_arg("review-github"),
5651 Some(Format::ReviewGithub)
5652 ));
5653 assert!(matches!(
5654 parse_format_arg("review-gitlab"),
5655 Some(Format::ReviewGitlab)
5656 ));
5657 assert!(matches!(parse_format_arg("badge"), Some(Format::Badge)));
5658 assert!(parse_format_arg("xml").is_none());
5659 assert!(parse_format_arg("").is_none());
5660 }
5661
5662 #[test]
5663 fn quiet_parsing_logic() {
5664 let parse = |s: &str| -> bool { s == "1" || s.eq_ignore_ascii_case("true") };
5665 assert!(parse("1"));
5666 assert!(parse("true"));
5667 assert!(parse("TRUE"));
5668 assert!(parse("True"));
5669 assert!(!parse("0"));
5670 assert!(!parse("false"));
5671 assert!(!parse("yes"));
5672 }
5673
5674 #[test]
5675 fn tracing_filter_defaults_to_warn_without_env() {
5676 assert_eq!(build_tracing_filter(None).to_string(), "warn");
5677 }
5678
5679 #[test]
5680 fn tracing_filter_respects_explicit_env_directives() {
5681 assert_eq!(build_tracing_filter(Some("info")).to_string(), "info");
5682 }
5683
5684 #[test]
5685 fn tracing_filter_treats_empty_env_as_off() {
5686 assert_eq!(build_tracing_filter(Some("")).to_string(), "off");
5687 assert_eq!(build_tracing_filter(Some(" ")).to_string(), "off");
5688 }
5689}