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