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_report_json(
3763 &value,
3764 "audit cache removal",
3765 dispatch.json_style,
3766 );
3767 if output_code != ExitCode::SUCCESS {
3768 return output_code;
3769 }
3770 } else if !dispatch.quiet {
3771 println!(
3772 "audit cache: {action} {}, skipped {} for {}",
3773 if *dry_run {
3774 report.found.saturating_sub(report.skipped)
3775 } else {
3776 report.removed
3777 },
3778 report.skipped,
3779 dispatch.root.display(),
3780 );
3781 }
3782 if report.skipped == 0 {
3783 ExitCode::SUCCESS
3784 } else {
3785 ExitCode::from(2)
3786 }
3787 }
3788 Err(error) => emit_error(
3789 &format!(
3790 "failed to remove audit caches for {}: {error}",
3791 dispatch.root.display()
3792 ),
3793 2,
3794 dispatch.output,
3795 ),
3796 }
3797 }
3798 }
3799}
3800
3801fn dispatch_flags_command(dispatch: &DispatchContext<'_>, top: Option<usize>) -> ExitCode {
3802 let cli = dispatch.cli;
3803 let root = dispatch.root;
3804 let output = dispatch.output;
3805 let quiet = dispatch.quiet;
3806 let threads = dispatch.threads;
3807 let production = match resolve_production_modes(cli, root, output, false, false, false) {
3808 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::DeadCode),
3809 Err(code) => return code,
3810 };
3811 flags::run_flags(&flags::FlagsOptions {
3812 root,
3813 config_path: &cli.config,
3814 output,
3815 json_style: dispatch.json_style,
3816 no_cache: cli.no_cache,
3817 threads,
3818 quiet,
3819 allow_remote_extends: cli.allow_remote_extends,
3820 production,
3821 workspace: cli.workspace.as_deref(),
3822 changed_workspaces: cli.changed_workspaces.as_deref(),
3823 changed_since: cli.changed_since.as_deref(),
3824 explain: cli.explain,
3825 top,
3826 })
3827}
3828
3829fn dispatch_suppressions_command(
3830 dispatch: &DispatchContext<'_>,
3831 file: &[std::path::PathBuf],
3832) -> ExitCode {
3833 let cli = dispatch.cli;
3834 let root = dispatch.root;
3835 let output = dispatch.output;
3836 let production = match resolve_production_modes(cli, root, output, false, false, false) {
3837 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::DeadCode),
3838 Err(code) => return code,
3839 };
3840 suppressions::run_suppressions(&suppressions::SuppressionsOptions {
3841 root,
3842 config_path: &cli.config,
3843 output,
3844 json_style: dispatch.json_style,
3845 no_cache: cli.no_cache,
3846 threads: dispatch.threads,
3847 quiet: dispatch.quiet,
3848 allow_remote_extends: cli.allow_remote_extends,
3849 production,
3850 workspace: cli.workspace.as_deref(),
3851 changed_workspaces: cli.changed_workspaces.as_deref(),
3852 changed_since: cli.changed_since.as_deref(),
3853 file,
3854 })
3855}
3856
3857fn dispatch_guard_command(dispatch: &DispatchContext<'_>, files: &[String]) -> ExitCode {
3858 guard::run_guard(&guard::GuardOptions {
3859 root: dispatch.root,
3860 config_path: &dispatch.cli.config,
3861 output: dispatch.output,
3862 json_style: dispatch.json_style,
3863 quiet: dispatch.quiet,
3864 allow_remote_extends: dispatch.cli.allow_remote_extends,
3865 files,
3866 })
3867}
3868
3869fn dispatch_rule_pack_command(dispatch: &DispatchContext<'_>, subcommand: RulePackCli) -> ExitCode {
3870 let ctx = rule_pack::RulePackContext {
3871 root: dispatch.root,
3872 config_path: &dispatch.cli.config,
3873 output: dispatch.output,
3874 json_style: dispatch.json_style,
3875 quiet: dispatch.quiet,
3876 no_cache: dispatch.cli.no_cache,
3877 threads: Some(dispatch.threads),
3878 allow_remote_extends: dispatch.cli.allow_remote_extends,
3879 };
3880 rule_pack::run(&map_rule_pack_subcommand(subcommand), &ctx)
3881}
3882
3883fn map_rule_pack_subcommand(subcommand: RulePackCli) -> rule_pack::RulePackSubcommand {
3884 match subcommand {
3885 RulePackCli::Init {
3886 name,
3887 template,
3888 dir,
3889 no_config,
3890 } => rule_pack::RulePackSubcommand::Init(rule_pack::InitArgs {
3891 name,
3892 template,
3893 dir,
3894 no_config,
3895 }),
3896 RulePackCli::List => rule_pack::RulePackSubcommand::List,
3897 RulePackCli::Test { pack } => {
3898 rule_pack::RulePackSubcommand::Test(rule_pack::TestArgs { pack })
3899 }
3900 RulePackCli::Schema => rule_pack::RulePackSubcommand::Schema,
3901 }
3902}
3903
3904fn map_license_subcommand(sub: LicenseCli) -> license::LicenseSubcommand {
3905 match sub {
3906 LicenseCli::Activate {
3907 jwt,
3908 from_file,
3909 stdin,
3910 trial,
3911 email,
3912 } => license::LicenseSubcommand::Activate(license::ActivateArgs {
3913 raw_jwt: jwt,
3914 from_file,
3915 from_stdin: stdin,
3916 trial,
3917 email,
3918 }),
3919 LicenseCli::Status => license::LicenseSubcommand::Status,
3920 LicenseCli::Refresh => license::LicenseSubcommand::Refresh,
3921 LicenseCli::Deactivate => license::LicenseSubcommand::Deactivate,
3922 }
3923}
3924
3925fn map_telemetry_subcommand(sub: TelemetryCli) -> telemetry::TelemetryCommand {
3926 match sub {
3927 TelemetryCli::Status => telemetry::TelemetryCommand::Status,
3928 TelemetryCli::Enable => telemetry::TelemetryCommand::Enable,
3929 TelemetryCli::Disable => telemetry::TelemetryCommand::Disable,
3930 TelemetryCli::Inspect { example } => telemetry::TelemetryCommand::Inspect { example },
3931 }
3932}
3933
3934fn map_ci_subcommand(sub: CiCli) -> ci::CiCommand {
3935 match sub {
3936 command @ CiCli::PlanPrComment { .. } => map_ci_plan_pr_comment(command),
3937 command @ CiCli::PostPrComment { .. } => map_ci_post_pr_comment(command),
3938 command @ CiCli::PostReview { .. } => map_ci_post_review(command),
3939 command @ CiCli::PostCheckRun { .. } => map_ci_post_check_run(command),
3940 command @ CiCli::ReconcileReview { .. } => map_ci_reconcile_review(command),
3941 }
3942}
3943
3944fn map_ci_plan_pr_comment(command: CiCli) -> ci::CiCommand {
3945 let CiCli::PlanPrComment {
3946 body,
3947 marker_id,
3948 clean,
3949 existing_comment_id,
3950 existing_body,
3951 } = command
3952 else {
3953 unreachable!("ci plan-pr-comment mapper called with different variant");
3954 };
3955
3956 ci::CiCommand::PlanPrComment {
3957 body,
3958 marker_id,
3959 clean,
3960 existing_comment_id,
3961 existing_body,
3962 }
3963}
3964
3965fn map_ci_post_pr_comment(command: CiCli) -> ci::CiCommand {
3966 let CiCli::PostPrComment {
3967 provider,
3968 pr,
3969 mr,
3970 body,
3971 envelope,
3972 marker_id,
3973 clean,
3974 repo,
3975 project_id,
3976 api_url,
3977 dry_run,
3978 } = command
3979 else {
3980 unreachable!("ci post-pr-comment mapper called with different variant");
3981 };
3982
3983 ci::CiCommand::PostPrComment {
3984 provider: map_ci_provider(provider),
3985 target: pr.or(mr),
3986 body,
3987 envelope,
3988 marker_id,
3989 clean,
3990 repo,
3991 project_id,
3992 api_url,
3993 dry_run,
3994 }
3995}
3996
3997fn map_ci_post_review(command: CiCli) -> ci::CiCommand {
3998 let CiCli::PostReview {
3999 provider,
4000 pr,
4001 mr,
4002 envelope,
4003 repo,
4004 project_id,
4005 api_url,
4006 dry_run,
4007 } = command
4008 else {
4009 unreachable!("ci post-review mapper called with different variant");
4010 };
4011
4012 ci::CiCommand::PostReview {
4013 provider: map_ci_provider(provider),
4014 target: pr.or(mr),
4015 envelope,
4016 repo,
4017 project_id,
4018 api_url,
4019 dry_run,
4020 }
4021}
4022
4023fn map_ci_post_check_run(command: CiCli) -> ci::CiCommand {
4024 let CiCli::PostCheckRun {
4025 provider,
4026 decision,
4027 repo,
4028 head_sha,
4029 api_url,
4030 split_gates,
4031 dry_run,
4032 } = command
4033 else {
4034 unreachable!("ci post-check-run mapper called with different variant");
4035 };
4036
4037 ci::CiCommand::PostCheckRun {
4038 provider: map_ci_provider(provider),
4039 decision,
4040 repo,
4041 head_sha,
4042 api_url,
4043 split_gates,
4044 dry_run,
4045 }
4046}
4047
4048fn map_ci_reconcile_review(command: CiCli) -> ci::CiCommand {
4049 let CiCli::ReconcileReview {
4050 provider,
4051 pr,
4052 mr,
4053 envelope,
4054 repo,
4055 project_id,
4056 api_url,
4057 dry_run,
4058 } = command
4059 else {
4060 unreachable!("ci reconcile-review mapper called with different variant");
4061 };
4062
4063 ci::CiCommand::ReconcileReview {
4064 provider: map_ci_provider(provider),
4065 target: pr.or(mr),
4066 envelope,
4067 repo,
4068 project_id,
4069 api_url,
4070 dry_run,
4071 }
4072}
4073
4074fn map_ci_provider(provider: CiProviderArg) -> ci::CiProvider {
4075 match provider {
4076 CiProviderArg::Github => ci::CiProvider::Github,
4077 CiProviderArg::Gitlab => ci::CiProvider::Gitlab,
4078 }
4079}
4080
4081fn map_coverage_subcommand(sub: &CoverageCli, explain: bool) -> coverage::CoverageSubcommand {
4082 match sub {
4083 CoverageCli::Setup {
4084 yes,
4085 non_interactive,
4086 json,
4087 } => map_coverage_setup(*yes, *non_interactive, *json, explain),
4088 CoverageCli::Analyze { .. } => map_coverage_analyze(sub),
4089 CoverageCli::UploadInventory { .. } => map_coverage_upload_inventory(sub),
4090 CoverageCli::UploadSourceMaps { .. } => map_coverage_upload_source_maps(sub),
4091 CoverageCli::UploadStaticFindings { .. } => map_coverage_upload_static_findings(sub),
4092 }
4093}
4094
4095fn map_coverage_setup(
4096 yes: bool,
4097 non_interactive: bool,
4098 json: bool,
4099 explain: bool,
4100) -> coverage::CoverageSubcommand {
4101 coverage::CoverageSubcommand::Setup(coverage::SetupArgs {
4102 yes,
4103 non_interactive: non_interactive || json,
4104 json,
4105 explain,
4106 })
4107}
4108
4109fn map_coverage_analyze(sub: &CoverageCli) -> coverage::CoverageSubcommand {
4110 let CoverageCli::Analyze {
4111 runtime_coverage,
4112 cloud,
4113 api_key,
4114 api_endpoint,
4115 repo,
4116 project_id,
4117 coverage_period,
4118 environment,
4119 commit_sha,
4120 production,
4121 min_invocations_hot,
4122 min_observation_volume,
4123 low_traffic_threshold,
4124 top,
4125 blast_radius,
4126 importance,
4127 } = sub
4128 else {
4129 unreachable!("coverage analyze mapper called with non-analyze variant");
4130 };
4131 coverage::CoverageSubcommand::Analyze(coverage::AnalyzeArgs {
4132 runtime_coverage: runtime_coverage.clone(),
4133 cloud: *cloud,
4134 api_key: api_key.clone(),
4135 api_endpoint: api_endpoint.clone(),
4136 repo: repo.clone(),
4137 project_id: project_id.clone(),
4138 coverage_period: *coverage_period,
4139 environment: environment.clone(),
4140 commit_sha: commit_sha.clone(),
4141 production: *production,
4142 min_invocations_hot: *min_invocations_hot,
4143 min_observation_volume: *min_observation_volume,
4144 low_traffic_threshold: *low_traffic_threshold,
4145 top: *top,
4146 blast_radius: *blast_radius,
4147 importance: *importance,
4148 })
4149}
4150
4151fn map_coverage_upload_inventory(sub: &CoverageCli) -> coverage::CoverageSubcommand {
4152 let CoverageCli::UploadInventory {
4153 api_key,
4154 api_endpoint,
4155 project_id,
4156 git_sha,
4157 allow_dirty,
4158 exclude_paths,
4159 path_prefix,
4160 dry_run,
4161 with_callers,
4162 ignore_upload_errors,
4163 } = sub
4164 else {
4165 unreachable!("coverage inventory mapper called with non-inventory variant");
4166 };
4167 coverage::CoverageSubcommand::UploadInventory(coverage::UploadInventoryArgs {
4168 api_key: api_key.clone(),
4169 api_endpoint: api_endpoint.clone(),
4170 project_id: project_id.clone(),
4171 git_sha: git_sha.clone(),
4172 allow_dirty: *allow_dirty,
4173 exclude_paths: exclude_paths.clone(),
4174 path_prefix: path_prefix.clone(),
4175 dry_run: *dry_run,
4176 with_callers: *with_callers,
4177 ignore_upload_errors: *ignore_upload_errors,
4178 })
4179}
4180
4181fn map_coverage_upload_source_maps(sub: &CoverageCli) -> coverage::CoverageSubcommand {
4182 let CoverageCli::UploadSourceMaps {
4183 dir,
4184 include,
4185 exclude,
4186 repo,
4187 git_sha,
4188 endpoint,
4189 strip_path,
4190 dry_run,
4191 concurrency,
4192 fail_fast,
4193 } = sub
4194 else {
4195 unreachable!("coverage source-map mapper called with non-source-map variant");
4196 };
4197 coverage::CoverageSubcommand::UploadSourceMaps(coverage::UploadSourceMapsArgs {
4198 dir: dir.clone(),
4199 include: include.clone(),
4200 exclude: exclude.clone(),
4201 repo: repo.clone(),
4202 git_sha: git_sha.clone(),
4203 endpoint: endpoint.clone(),
4204 strip_path: *strip_path,
4205 dry_run: *dry_run,
4206 concurrency: *concurrency,
4207 fail_fast: *fail_fast,
4208 })
4209}
4210
4211fn map_coverage_upload_static_findings(sub: &CoverageCli) -> coverage::CoverageSubcommand {
4212 let CoverageCli::UploadStaticFindings {
4213 api_key,
4214 api_endpoint,
4215 project_id,
4216 git_sha,
4217 allow_dirty,
4218 dry_run,
4219 ignore_upload_errors,
4220 } = sub
4221 else {
4222 unreachable!("coverage static-findings mapper called with non-static variant");
4223 };
4224 coverage::CoverageSubcommand::UploadStaticFindings(coverage::UploadStaticFindingsArgs {
4225 api_key: api_key.clone(),
4226 api_endpoint: api_endpoint.clone(),
4227 project_id: project_id.clone(),
4228 git_sha: git_sha.clone(),
4229 allow_dirty: *allow_dirty,
4230 dry_run: *dry_run,
4231 ignore_upload_errors: *ignore_upload_errors,
4232 })
4233}
4234
4235struct CheckDispatchArgs {
4236 filters: IssueFilters,
4237 trace_opts: TraceOptions,
4238 include_dupes: bool,
4239 top: Option<usize>,
4240 file: Vec<std::path::PathBuf>,
4241}
4242
4243#[derive(Clone, Copy)]
4244struct ListDispatchArgs {
4245 entry_points: bool,
4246 files: bool,
4247 plugins: bool,
4248 boundaries: bool,
4249 workspaces: bool,
4250}
4251
4252impl ListDispatchArgs {
4253 fn workspaces() -> Self {
4254 Self {
4255 entry_points: false,
4256 files: false,
4257 plugins: false,
4258 boundaries: false,
4259 workspaces: true,
4260 }
4261 }
4262}
4263
4264fn dispatch_watch(dispatch: &DispatchContext<'_>, no_clear: bool) -> ExitCode {
4265 let cli = dispatch.cli;
4266 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
4267 Ok(production) => production,
4268 Err(code) => return code,
4269 };
4270 watch::run_watch(&watch::WatchOptions {
4271 root: dispatch.root,
4272 config_path: &cli.config,
4273 output: dispatch.output,
4274 json_style: dispatch.json_style,
4275 no_cache: cli.no_cache,
4276 threads: dispatch.threads,
4277 quiet: dispatch.quiet,
4278 allow_remote_extends: cli.allow_remote_extends,
4279 production,
4280 clear_screen: !no_clear,
4281 explain: cli.explain,
4282 include_entry_exports: cli.include_entry_exports,
4283 })
4284}
4285
4286#[derive(Clone, Copy)]
4287struct FixDispatchArgs {
4288 dry_run: bool,
4289 yes: bool,
4290 no_create_config: bool,
4291}
4292
4293fn dispatch_fix(dispatch: &DispatchContext<'_>, args: FixDispatchArgs) -> ExitCode {
4294 let cli = dispatch.cli;
4295 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
4296 Ok(production) => production,
4297 Err(code) => return code,
4298 };
4299 fix::run_fix(&fix::FixOptions {
4300 root: dispatch.root,
4301 config_path: &cli.config,
4302 output: dispatch.output,
4303 json_style: dispatch.json_style,
4304 no_cache: cli.no_cache,
4305 threads: dispatch.threads,
4306 quiet: dispatch.quiet,
4307 allow_remote_extends: cli.allow_remote_extends,
4308 dry_run: args.dry_run,
4309 yes: args.yes,
4310 production,
4311 no_create_config: args.no_create_config,
4312 })
4313}
4314
4315fn dispatch_list(dispatch: &DispatchContext<'_>, args: ListDispatchArgs) -> ExitCode {
4316 let cli = dispatch.cli;
4317 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
4318 Ok(production) => production,
4319 Err(code) => return code,
4320 };
4321 list::run_list(&ListOptions {
4322 root: dispatch.root,
4323 config_path: &cli.config,
4324 output: dispatch.output,
4325 json_style: dispatch.json_style,
4326 threads: dispatch.threads,
4327 no_cache: cli.no_cache,
4328 entry_points: args.entry_points,
4329 files: args.files,
4330 plugins: args.plugins,
4331 boundaries: args.boundaries,
4332 workspaces: args.workspaces,
4333 production,
4334 allow_remote_extends: cli.allow_remote_extends,
4335 })
4336}
4337
4338fn dispatch_check(dispatch: &DispatchContext<'_>, args: &CheckDispatchArgs) -> ExitCode {
4339 let cli = dispatch.cli;
4340 let (output, quiet, fail_on_issues) =
4341 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
4342 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
4343 Ok(production) => production,
4344 Err(code) => return code,
4345 };
4346 check::run_check(&CheckOptions {
4347 root: dispatch.root,
4348 config_path: &cli.config,
4349 output,
4350 json_style: dispatch.json_style,
4351 no_cache: cli.no_cache,
4352 threads: dispatch.threads,
4353 quiet,
4354 allow_remote_extends: cli.allow_remote_extends,
4355 fail_on_issues,
4356 filters: &args.filters,
4357 changed_since: cli.changed_since.as_deref(),
4358 diff_index: None,
4359 use_shared_diff_index: true,
4360 baseline: cli.baseline.as_deref(),
4361 save_baseline: cli.save_baseline.as_deref(),
4362 sarif_file: cli.sarif_file.as_deref(),
4363 production,
4364 production_override: Some(production),
4365 workspace: cli.workspace.as_deref(),
4366 changed_workspaces: cli.changed_workspaces.as_deref(),
4367 group_by: cli.group_by,
4368 include_dupes: args.include_dupes,
4369 trace_opts: &args.trace_opts,
4370 explain: cli.explain,
4371 top: args.top,
4372 file: &args.file,
4373 include_entry_exports: cli.include_entry_exports,
4374 summary: cli.summary,
4375 regression_opts: dispatch.regression_opts(
4376 cli.changed_since.is_some()
4377 || cli.workspace.is_some()
4378 || cli.changed_workspaces.is_some()
4379 || !args.file.is_empty(),
4380 ),
4381 retain_modules_for_health: false,
4382 defer_performance: false,
4383 })
4384}
4385
4386fn resolve_ignore_imports(ignore_imports: bool, no_ignore_imports: bool) -> Option<bool> {
4392 if no_ignore_imports {
4393 Some(false)
4394 } else if ignore_imports {
4395 Some(true)
4396 } else {
4397 None
4398 }
4399}
4400
4401struct DupesDispatchArgs {
4402 mode: Option<DupesMode>,
4403 min_tokens: Option<usize>,
4404 min_lines: Option<usize>,
4405 min_occurrences: Option<usize>,
4406 threshold: Option<f64>,
4407 skip_local: bool,
4408 cross_language: bool,
4409 ignore_imports: bool,
4410 no_ignore_imports: bool,
4411 top: Option<usize>,
4412 trace: Option<String>,
4413}
4414
4415fn dispatch_dupes(dispatch: &DispatchContext<'_>, args: &DupesDispatchArgs) -> ExitCode {
4416 let cli = dispatch.cli;
4417 let (output, quiet, _fail_on_issues) =
4418 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
4419 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::Dupes) {
4420 Ok(production) => production,
4421 Err(code) => return code,
4422 };
4423 dupes::run_dupes(&DupesOptions {
4424 root: dispatch.root,
4425 config_path: &cli.config,
4426 output,
4427 json_style: dispatch.json_style,
4428 no_cache: cli.no_cache,
4429 threads: dispatch.threads,
4430 quiet,
4431 allow_remote_extends: cli.allow_remote_extends,
4432 mode: args.mode,
4433 min_tokens: args.min_tokens,
4434 min_lines: args.min_lines,
4435 min_occurrences: args.min_occurrences,
4436 threshold: args.threshold,
4437 skip_local: args.skip_local,
4438 cross_language: args.cross_language,
4439 ignore_imports: resolve_ignore_imports(args.ignore_imports, args.no_ignore_imports),
4440 top: args.top,
4441 baseline_path: cli.baseline.as_deref(),
4442 save_baseline_path: cli.save_baseline.as_deref(),
4443 production,
4444 production_override: Some(production),
4445 trace: args.trace.as_deref(),
4446 changed_since: cli.changed_since.as_deref(),
4447 diff_index: None,
4448 use_shared_diff_index: true,
4449 changed_files: None,
4450 workspace: cli.workspace.as_deref(),
4451 changed_workspaces: cli.changed_workspaces.as_deref(),
4452 explain: cli.explain,
4453 explain_skipped: cli.explain_skipped,
4454 summary: cli.summary,
4455 group_by: cli.group_by,
4456 performance: cli.performance,
4457 })
4458}
4459
4460struct AuditDispatchArgs {
4461 production_dead_code: bool,
4462 production_health: bool,
4463 production_dupes: bool,
4464 dead_code_baseline: Option<PathBuf>,
4465 health_baseline: Option<PathBuf>,
4466 dupes_baseline: Option<PathBuf>,
4467 max_crap: Option<f64>,
4468 coverage: Option<PathBuf>,
4469 coverage_root: Option<PathBuf>,
4470 no_css: bool,
4471 css_deep: bool,
4472 no_css_deep: bool,
4473 gate: Option<AuditGateArg>,
4474 runtime_coverage: Option<PathBuf>,
4475 min_invocations_hot: u64,
4476 gate_marker: Option<String>,
4477 brief: bool,
4478 max_decisions: usize,
4479 walkthrough_guide: bool,
4481 walkthrough_file: Option<PathBuf>,
4484 walkthrough: bool,
4486 mark_viewed: Vec<PathBuf>,
4488 show_cleared: bool,
4490 show_deprioritized: bool,
4492}
4493
4494struct ResolvedAuditInputs {
4495 audit_cfg: fallow_config::AuditConfig,
4496 cache_dir: PathBuf,
4497 production: ProductionModes,
4498 dead_code_baseline: Option<PathBuf>,
4499 health_baseline: Option<PathBuf>,
4500 dupes_baseline: Option<PathBuf>,
4501 coverage: Option<PathBuf>,
4502}
4503
4504fn dispatch_audit(dispatch: &DispatchContext<'_>, args: &AuditDispatchArgs) -> ExitCode {
4505 let cli = dispatch.cli;
4506 let output = dispatch.output;
4507
4508 if cli.baseline.is_some() || cli.save_baseline.is_some() {
4509 return emit_error(
4510 "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>`)",
4511 2,
4512 output,
4513 );
4514 }
4515
4516 let inputs = match resolve_audit_inputs(dispatch, args) {
4517 Ok(inputs) => inputs,
4518 Err(code) => return code,
4519 };
4520
4521 run_resolved_audit(dispatch, args, &inputs)
4522}
4523
4524fn resolve_audit_inputs(
4525 dispatch: &DispatchContext<'_>,
4526 args: &AuditDispatchArgs,
4527) -> Result<ResolvedAuditInputs, ExitCode> {
4528 let cli = dispatch.cli;
4529 let root = dispatch.root;
4530 let output = dispatch.output;
4531 let config = load_config(
4532 root,
4533 &cli.config,
4534 LoadConfigArgs {
4535 output,
4536 no_cache: cli.no_cache,
4537 threads: dispatch.threads,
4538 production: cli.production,
4539 quiet: dispatch.quiet,
4540 allow_remote_extends: cli.allow_remote_extends,
4541 },
4542 )?;
4543 let cache_dir = config.cache_dir.clone();
4544 let audit_cfg = config.audit;
4545 let production = resolve_production_modes(
4546 cli,
4547 root,
4548 output,
4549 args.production_dead_code,
4550 args.production_health,
4551 args.production_dupes,
4552 )?;
4553 let resolved_dead_code_baseline = resolve_audit_baseline_path(
4554 root,
4555 args.dead_code_baseline.as_deref(),
4556 audit_cfg.dead_code_baseline.as_deref(),
4557 );
4558 let resolved_health_baseline = resolve_audit_baseline_path(
4559 root,
4560 args.health_baseline.as_deref(),
4561 audit_cfg.health_baseline.as_deref(),
4562 );
4563 let resolved_dupes_baseline = resolve_audit_baseline_path(
4564 root,
4565 args.dupes_baseline.as_deref(),
4566 audit_cfg.dupes_baseline.as_deref(),
4567 );
4568 let coverage = args
4569 .coverage
4570 .clone()
4571 .or_else(|| std::env::var("FALLOW_COVERAGE").ok().map(PathBuf::from));
4572
4573 Ok(ResolvedAuditInputs {
4574 audit_cfg,
4575 cache_dir,
4576 production,
4577 dead_code_baseline: resolved_dead_code_baseline,
4578 health_baseline: resolved_health_baseline,
4579 dupes_baseline: resolved_dupes_baseline,
4580 coverage,
4581 })
4582}
4583
4584fn audit_css_enabled(config: &fallow_config::AuditConfig, args: &AuditDispatchArgs) -> bool {
4585 !args.no_css && config.css.unwrap_or(true)
4586}
4587
4588fn audit_css_deep_enabled(config: &fallow_config::AuditConfig, args: &AuditDispatchArgs) -> bool {
4589 audit_css_enabled(config, args)
4590 && !args.no_css_deep
4591 && (args.css_deep || config.css_deep.unwrap_or(true))
4592}
4593
4594fn run_resolved_audit(
4595 dispatch: &DispatchContext<'_>,
4596 args: &AuditDispatchArgs,
4597 inputs: &ResolvedAuditInputs,
4598) -> ExitCode {
4599 let cli = dispatch.cli;
4600 audit::run_audit(
4601 &audit::AuditOptions {
4602 root: dispatch.root,
4603 config_path: &cli.config,
4604 cache_dir: &inputs.cache_dir,
4605 output: dispatch.output,
4606 json_style: dispatch.json_style,
4607 no_cache: cli.no_cache,
4608 threads: dispatch.threads,
4609 quiet: dispatch.quiet,
4610 allow_remote_extends: cli.allow_remote_extends,
4611 changed_since: cli.changed_since.as_deref(),
4612 production: cli.production,
4613 production_dead_code: Some(inputs.production.dead_code),
4614 production_health: Some(inputs.production.health),
4615 production_dupes: Some(inputs.production.dupes),
4616 workspace: cli.workspace.as_deref(),
4617 changed_workspaces: cli.changed_workspaces.as_deref(),
4618 explain: cli.explain,
4619 explain_skipped: cli.explain_skipped,
4620 performance: cli.performance,
4621 group_by: cli.group_by,
4622 dead_code_baseline: inputs.dead_code_baseline.as_deref(),
4623 health_baseline: inputs.health_baseline.as_deref(),
4624 dupes_baseline: inputs.dupes_baseline.as_deref(),
4625 max_crap: args.max_crap,
4626 coverage: inputs.coverage.as_deref(),
4627 coverage_root: args.coverage_root.as_deref(),
4628 gate: args.gate.map_or(inputs.audit_cfg.gate, Into::into),
4629 include_entry_exports: cli.include_entry_exports,
4630 css: audit_css_enabled(&inputs.audit_cfg, args),
4634 css_deep: audit_css_deep_enabled(&inputs.audit_cfg, args),
4635 runtime_coverage: args.runtime_coverage.as_deref(),
4636 min_invocations_hot: args.min_invocations_hot,
4637 brief: args.brief,
4638 max_decisions: args.max_decisions,
4639 walkthrough_guide: args.walkthrough_guide,
4640 walkthrough: args.walkthrough,
4641 mark_viewed: &args.mark_viewed,
4642 show_cleared: args.show_cleared,
4643 walkthrough_file: args.walkthrough_file.as_deref(),
4644 show_deprioritized: args.show_deprioritized,
4645 },
4646 args.gate_marker.as_deref(),
4647 )
4648}
4649
4650fn dispatch_decision_surface(dispatch: &DispatchContext<'_>, max_decisions: usize) -> ExitCode {
4654 let args = decision_surface_audit_args(max_decisions);
4655 let inputs = match resolve_audit_inputs(dispatch, &args) {
4656 Ok(inputs) => inputs,
4657 Err(code) => return code,
4658 };
4659 audit::run_decision_surface(&decision_surface_audit_options(
4660 dispatch,
4661 &inputs,
4662 max_decisions,
4663 ))
4664}
4665
4666fn decision_surface_audit_args(max_decisions: usize) -> AuditDispatchArgs {
4667 AuditDispatchArgs {
4668 production_dead_code: false,
4669 production_health: false,
4670 production_dupes: false,
4671 dead_code_baseline: None,
4672 health_baseline: None,
4673 dupes_baseline: None,
4674 max_crap: None,
4675 coverage: None,
4676 coverage_root: None,
4677 no_css: true,
4678 css_deep: false,
4679 no_css_deep: false,
4680 gate: None,
4681 runtime_coverage: None,
4682 min_invocations_hot: 0,
4683 gate_marker: None,
4684 brief: true,
4685 max_decisions,
4686 walkthrough_guide: false,
4687 walkthrough_file: None,
4688 walkthrough: false,
4689 mark_viewed: Vec::new(),
4690 show_cleared: false,
4691 show_deprioritized: false,
4692 }
4693}
4694
4695fn decision_surface_audit_options<'a>(
4696 dispatch: &'a DispatchContext<'a>,
4697 inputs: &'a ResolvedAuditInputs,
4698 max_decisions: usize,
4699) -> audit::AuditOptions<'a> {
4700 let cli = dispatch.cli;
4701 audit::AuditOptions {
4702 root: dispatch.root,
4703 config_path: &cli.config,
4704 cache_dir: &inputs.cache_dir,
4705 output: dispatch.output,
4706 json_style: dispatch.json_style,
4707 no_cache: cli.no_cache,
4708 threads: dispatch.threads,
4709 quiet: dispatch.quiet,
4710 allow_remote_extends: cli.allow_remote_extends,
4711 changed_since: cli.changed_since.as_deref(),
4712 production: cli.production,
4713 production_dead_code: Some(inputs.production.dead_code),
4714 production_health: Some(inputs.production.health),
4715 production_dupes: Some(inputs.production.dupes),
4716 workspace: cli.workspace.as_deref(),
4717 changed_workspaces: cli.changed_workspaces.as_deref(),
4718 explain: cli.explain,
4719 explain_skipped: cli.explain_skipped,
4720 performance: cli.performance,
4721 group_by: cli.group_by,
4722 dead_code_baseline: inputs.dead_code_baseline.as_deref(),
4723 health_baseline: inputs.health_baseline.as_deref(),
4724 dupes_baseline: inputs.dupes_baseline.as_deref(),
4725 max_crap: None,
4726 coverage: None,
4727 coverage_root: None,
4728 gate: inputs.audit_cfg.gate,
4729 include_entry_exports: cli.include_entry_exports,
4730 css: false,
4732 css_deep: false,
4733 runtime_coverage: None,
4734 min_invocations_hot: 0,
4735 brief: true,
4736 max_decisions,
4737 walkthrough_guide: false,
4738 walkthrough: false,
4739 mark_viewed: &[],
4740 show_cleared: false,
4741 walkthrough_file: None,
4742 show_deprioritized: false,
4743 }
4744}
4745
4746struct HealthDispatchArgs<'a> {
4747 max_cyclomatic: Option<u16>,
4748 max_cognitive: Option<u16>,
4749 max_crap: Option<f64>,
4750 top: Option<usize>,
4751 sort: health::SortBy,
4752 complexity: bool,
4753 complexity_breakdown: bool,
4754 file_scores: bool,
4755 coverage_gaps: bool,
4756 hotspots: bool,
4757 ownership: bool,
4758 ownership_emails: Option<fallow_config::EmailMode>,
4759 targets: bool,
4760 css: bool,
4761 effort: Option<EffortFilter>,
4762 score: bool,
4763 min_score: Option<f64>,
4764 min_severity: Option<fallow_output::FindingSeverity>,
4765 report_only: bool,
4766 since: Option<&'a str>,
4767 min_commits: Option<u32>,
4768 save_snapshot: Option<&'a Option<String>>,
4769 trend: bool,
4770 coverage: Option<&'a std::path::Path>,
4771 coverage_root: Option<&'a std::path::Path>,
4772 runtime_coverage: Option<&'a std::path::Path>,
4773 min_invocations_hot: u64,
4774 min_observation_volume: Option<u32>,
4775 low_traffic_threshold: Option<f64>,
4776}
4777
4778struct ResolvedHealthCoverageInputs {
4779 coverage: Option<PathBuf>,
4780 coverage_root: Option<PathBuf>,
4781}
4782
4783fn resolve_health_coverage_inputs(
4784 dispatch: &DispatchContext<'_>,
4785 cli_coverage: Option<&std::path::Path>,
4786 cli_coverage_root: Option<&std::path::Path>,
4787) -> Result<ResolvedHealthCoverageInputs, ExitCode> {
4788 let env_coverage = path_from_env("FALLOW_COVERAGE");
4789 let env_coverage_root = path_from_env("FALLOW_COVERAGE_ROOT");
4790 let needs_config_coverage = cli_coverage.is_none() && env_coverage.is_none();
4791 let needs_config_coverage_root = cli_coverage_root.is_none() && env_coverage_root.is_none();
4792 let config_health = if needs_config_coverage || needs_config_coverage_root {
4793 Some(
4794 load_config(
4795 dispatch.root,
4796 &dispatch.cli.config,
4797 LoadConfigArgs {
4798 output: dispatch.output,
4799 no_cache: dispatch.cli.no_cache,
4800 threads: dispatch.threads,
4801 production: dispatch.cli.production,
4802 quiet: dispatch.quiet,
4803 allow_remote_extends: dispatch.cli.allow_remote_extends,
4804 },
4805 )?
4806 .health,
4807 )
4808 } else {
4809 None
4810 };
4811
4812 Ok(ResolvedHealthCoverageInputs {
4813 coverage: cli_coverage
4814 .map(std::path::Path::to_path_buf)
4815 .or(env_coverage)
4816 .or_else(|| {
4817 config_health
4818 .as_ref()
4819 .and_then(|health| health.coverage.clone())
4820 }),
4821 coverage_root: cli_coverage_root
4822 .map(std::path::Path::to_path_buf)
4823 .or(env_coverage_root)
4824 .or_else(|| {
4825 config_health
4826 .as_ref()
4827 .and_then(|health| health.coverage_root.clone())
4828 }),
4829 })
4830}
4831
4832fn path_from_env(name: &str) -> Option<PathBuf> {
4833 std::env::var_os(name)
4834 .filter(|value| !value.is_empty())
4835 .map(PathBuf::from)
4836}
4837
4838fn validate_health_report_only_gate(
4839 report_only: bool,
4840 min_score: Option<f64>,
4841 min_severity: Option<fallow_output::FindingSeverity>,
4842 output: fallow_config::OutputFormat,
4843) -> Result<(), ExitCode> {
4844 if report_only && (min_score.is_some() || min_severity.is_some()) {
4845 return Err(emit_error(
4846 "--report-only cannot be combined with --min-score or --min-severity. \
4847 --report-only always exits 0; drop it to gate on score/severity, or \
4848 drop the gate flags to stay advisory.",
4849 2,
4850 output,
4851 ));
4852 }
4853
4854 Ok(())
4855}
4856
4857fn resolve_runtime_coverage_options(
4858 runtime_coverage: Option<&std::path::Path>,
4859 min_invocations_hot: u64,
4860 min_observation_volume: Option<u32>,
4861 low_traffic_threshold: Option<f64>,
4862 output: fallow_config::OutputFormat,
4863) -> Result<Option<fallow_engine::health::RuntimeCoverageOptions>, ExitCode> {
4864 let Some(path) = runtime_coverage else {
4865 return Ok(None);
4866 };
4867
4868 health::coverage::prepare_options(
4869 path,
4870 min_invocations_hot,
4871 min_observation_volume,
4872 low_traffic_threshold,
4873 output,
4874 )
4875 .map(Some)
4876}
4877
4878fn dispatch_health(dispatch: &DispatchContext<'_>, args: &HealthDispatchArgs<'_>) -> ExitCode {
4879 let cli = dispatch.cli;
4880 let root = dispatch.root;
4881 let (output, _quiet, _fail_on_issues) =
4882 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
4883 if let Err(code) = validate_health_report_only_gate(
4884 args.report_only,
4885 args.min_score,
4886 args.min_severity,
4887 output,
4888 ) {
4889 return code;
4890 }
4891 let runtime_coverage = match resolve_runtime_coverage_options(
4892 args.runtime_coverage,
4893 args.min_invocations_hot,
4894 args.min_observation_volume,
4895 args.low_traffic_threshold,
4896 output,
4897 ) {
4898 Ok(options) => options,
4899 Err(code) => return code,
4900 };
4901 let production = match resolve_production_modes(cli, root, output, false, false, false) {
4902 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::Health),
4903 Err(code) => return code,
4904 };
4905 let coverage_inputs =
4906 match resolve_health_coverage_inputs(dispatch, args.coverage, args.coverage_root) {
4907 Ok(inputs) => inputs,
4908 Err(code) => return code,
4909 };
4910 let run = derive_health_dispatch_run(args, output, &coverage_inputs, runtime_coverage);
4911 run_health_dispatch(dispatch, args, ResolvedHealthDispatch { run, production })
4912}
4913
4914fn derive_health_dispatch_run<'a>(
4915 args: &'a HealthDispatchArgs<'a>,
4916 output: fallow_config::OutputFormat,
4917 coverage_inputs: &'a ResolvedHealthCoverageInputs,
4918 runtime_coverage: Option<fallow_engine::health::RuntimeCoverageOptions>,
4919) -> fallow_engine::health::HealthRunOptions<'a> {
4920 fallow_engine::health::derive_health_run_options(fallow_engine::health::HealthRunOptionsInput {
4921 output,
4922 thresholds: health_threshold_overrides(args),
4923 top: args.top,
4924 sort: args.sort.clone().into(),
4925 complexity: args.complexity,
4926 file_scores: args.file_scores,
4927 coverage_gaps: args.coverage_gaps,
4928 hotspots: args.hotspots,
4929 ownership: args.ownership,
4930 ownership_emails: args.ownership_emails,
4931 targets: args.targets,
4932 css: args.css,
4933 effort: args.effort.map(EffortFilter::to_estimate),
4934 score: args.score,
4935 gates: health_gate_options(args),
4936 snapshot_requested: args.save_snapshot.is_some(),
4937 trend: args.trend,
4938 since: args.since,
4939 min_commits: args.min_commits,
4940 coverage_inputs: health_coverage_inputs(coverage_inputs),
4941 runtime_coverage,
4942 })
4943}
4944
4945fn health_threshold_overrides(
4946 args: &HealthDispatchArgs<'_>,
4947) -> fallow_engine::health::HealthThresholdOverrides {
4948 fallow_engine::health::HealthThresholdOverrides {
4949 max_cyclomatic: args.max_cyclomatic,
4950 max_cognitive: args.max_cognitive,
4951 max_crap: args.max_crap,
4952 }
4953}
4954
4955fn health_gate_options(args: &HealthDispatchArgs<'_>) -> fallow_engine::health::HealthGateOptions {
4956 fallow_engine::health::HealthGateOptions {
4957 min_score: args.min_score,
4958 min_severity: args.min_severity,
4959 report_only: args.report_only,
4960 }
4961}
4962
4963fn health_coverage_inputs(
4964 coverage_inputs: &ResolvedHealthCoverageInputs,
4965) -> fallow_engine::health::HealthCoverageInputs<'_> {
4966 fallow_engine::health::HealthCoverageInputs {
4967 coverage: coverage_inputs.coverage.as_deref(),
4968 coverage_root: coverage_inputs.coverage_root.as_deref(),
4969 }
4970}
4971
4972struct ResolvedHealthDispatch<'a> {
4976 run: fallow_engine::health::HealthRunOptions<'a>,
4977 production: bool,
4978}
4979
4980fn run_health_dispatch(
4983 dispatch: &DispatchContext<'_>,
4984 args: &HealthDispatchArgs<'_>,
4985 resolved: ResolvedHealthDispatch<'_>,
4986) -> ExitCode {
4987 let cli = dispatch.cli;
4988 let (output, quiet, _fail_on_issues) =
4989 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
4990 let run = resolved.run;
4991 let sections = run.sections;
4992 let production = resolved.production;
4993 health::run_health(
4994 &HealthOptions {
4995 root: dispatch.root,
4996 config_path: &cli.config,
4997 output,
4998 no_cache: cli.no_cache,
4999 threads: dispatch.threads,
5000 quiet,
5001 thresholds: run.thresholds,
5002 top: run.top,
5003 sort: run.sort,
5004 production,
5005 production_override: Some(production),
5006 allow_remote_extends: cli.allow_remote_extends,
5007 changed_since: cli.changed_since.as_deref(),
5008 diff_index: None,
5009 use_shared_diff_index: true,
5010 workspace: cli.workspace.as_deref(),
5011 changed_workspaces: cli.changed_workspaces.as_deref(),
5012 baseline: cli.baseline.as_deref(),
5013 save_baseline: cli.save_baseline.as_deref(),
5014 complexity: sections.complexity,
5015 file_scores: sections.file_scores,
5016 coverage_gaps: sections.coverage_gaps,
5017 config_activates_coverage_gaps: !sections.any_section,
5018 hotspots: sections.hotspots,
5019 ownership: run.ownership,
5020 ownership_emails: run.ownership_emails,
5021 targets: sections.targets,
5022 css: sections.css,
5023 css_deep: false,
5024 force_full: sections.force_full,
5025 score_only_output: sections.score_only_output,
5026 enforce_coverage_gap_gate: true,
5027 effort: run.effort,
5028 score: sections.score,
5029 gates: run.gates,
5030 since: run.since,
5031 min_commits: run.min_commits,
5032 explain: cli.explain,
5033 summary: cli.summary,
5034 save_snapshot: args
5035 .save_snapshot
5036 .map(|opt| PathBuf::from(opt.as_deref().unwrap_or_default())),
5037 trend: args.trend,
5038 coverage_inputs: run.coverage_inputs,
5039 performance: cli.performance,
5040 runtime_coverage: run.runtime_coverage,
5041 churn_file: cli.churn_file.as_deref(),
5042 complexity_breakdown: args.complexity_breakdown,
5043 group_by: cli.group_by.map(Into::into),
5044 },
5045 dispatch.json_style,
5046 )
5047}
5048
5049#[cfg(test)]
5050mod tests {
5051 use super::*;
5052
5053 #[test]
5057 fn cli_definition_has_no_flag_collisions() {
5058 use clap::CommandFactory;
5059 Cli::command().debug_assert();
5060 }
5061
5062 #[test]
5063 fn regression_baseline_help_explains_the_default_destination() {
5064 use clap::CommandFactory;
5065 let help = Cli::command().render_long_help().to_string();
5066
5067 assert!(help.contains("Omit PATH to update regression.baseline"));
5068 assert!(help.contains("discovered fallow config"));
5069 assert!(help.contains("create .fallowrc.json when none exists"));
5070 }
5071
5072 #[test]
5076 fn after_help_lists_every_task_matrix_command() {
5077 for row in crate::task_matrix::TASK_MATRIX {
5078 assert!(
5079 TOP_LEVEL_AFTER_HELP.contains(row.command),
5080 "root --help cheat sheet is missing task-matrix command '{}'; \
5081 update TOP_LEVEL_AFTER_HELP to match TASK_MATRIX",
5082 row.command
5083 );
5084 }
5085 }
5086
5087 #[test]
5091 fn high_value_commands_route_to_distinct_workflows() {
5092 use clap::Parser;
5093 use fallow_config::OutputFormat;
5094
5095 let distinct = [
5096 (vec!["fallow", "impact"], telemetry::Workflow::Impact),
5097 (vec!["fallow", "security"], telemetry::Workflow::Security),
5098 (vec!["fallow", "fix"], telemetry::Workflow::Fix),
5099 (
5100 vec!["fallow", "explain", "unused-exports"],
5101 telemetry::Workflow::Explain,
5102 ),
5103 (
5104 vec!["fallow", "watch"],
5105 telemetry::Workflow::CodeQualityReview,
5106 ),
5107 (
5108 vec!["fallow", "list"],
5109 telemetry::Workflow::ProjectInventory,
5110 ),
5111 (
5112 vec!["fallow", "workspaces"],
5113 telemetry::Workflow::ProjectInventory,
5114 ),
5115 (
5116 vec!["fallow", "schema"],
5117 telemetry::Workflow::ProjectInventory,
5118 ),
5119 (vec!["fallow", "init"], telemetry::Workflow::Setup),
5120 (
5121 vec!["fallow", "hooks", "install", "--target", "git"],
5122 telemetry::Workflow::Setup,
5123 ),
5124 (vec!["fallow", "config-schema"], telemetry::Workflow::Setup),
5125 (vec!["fallow", "plugin-schema"], telemetry::Workflow::Setup),
5126 (
5127 vec!["fallow", "rule-pack-schema"],
5128 telemetry::Workflow::Setup,
5129 ),
5130 (vec!["fallow", "config"], telemetry::Workflow::Setup),
5131 (
5132 vec!["fallow", "ci-template", "gitlab"],
5133 telemetry::Workflow::Setup,
5134 ),
5135 (vec!["fallow", "migrate"], telemetry::Workflow::Setup),
5136 (
5137 vec!["fallow", "telemetry", "status"],
5138 telemetry::Workflow::Setup,
5139 ),
5140 (vec!["fallow", "setup-hooks"], telemetry::Workflow::Setup),
5141 (
5142 vec!["fallow", "audit-cache", "remove", "--root", "."],
5143 telemetry::Workflow::Setup,
5144 ),
5145 (
5146 vec!["fallow", "license", "status"],
5147 telemetry::Workflow::License,
5148 ),
5149 ];
5150 for (argv, expected) in distinct {
5151 let cli = Cli::try_parse_from(&argv).expect("argv parses");
5152 assert_eq!(
5153 telemetry_workflow_for_command(cli.command.as_ref(), OutputFormat::Json),
5154 expected,
5155 "{argv:?} should map to {expected:?}"
5156 );
5157 }
5158 }
5159
5160 #[test]
5165 fn version_flag_accepts_lower_v_upper_v_and_long() {
5166 use clap::CommandFactory;
5167 for argv in [["fallow", "-v"], ["fallow", "-V"], ["fallow", "--version"]] {
5168 let err = Cli::command()
5169 .try_get_matches_from(argv)
5170 .expect_err("version flag should short-circuit parsing");
5171 assert_eq!(
5172 err.kind(),
5173 clap::error::ErrorKind::DisplayVersion,
5174 "{argv:?} should trigger the Version action"
5175 );
5176 }
5177 }
5178
5179 #[test]
5184 fn cli_help_text_contains_no_implementation_status_wording() {
5185 use clap::CommandFactory;
5186 let mut root = Cli::command();
5187 let mut violations: Vec<(String, String)> = Vec::new();
5188 visit_help(&mut root, "fallow", &mut violations);
5189 assert!(
5190 violations.is_empty(),
5191 "found implementation-status wording in --help output:\n{}",
5192 violations
5193 .iter()
5194 .map(|(cmd, line)| format!(" {cmd}: {line}"))
5195 .collect::<Vec<_>>()
5196 .join("\n")
5197 );
5198 }
5199
5200 #[test]
5201 fn top_level_help_groups_commands_by_workflow() {
5202 use clap::CommandFactory;
5203 let help = Cli::command().render_long_help().to_string();
5204 let expected_order = [
5205 "Analysis:",
5206 " dead-code",
5207 " dupes",
5208 " health",
5209 " flags",
5210 " security",
5211 " audit",
5212 "Workflow:",
5213 " watch",
5214 " fix",
5215 "Project inspection:",
5216 " list",
5217 " workspaces",
5218 " explain",
5219 " impact",
5220 "Setup and configuration:",
5221 " init",
5222 " recommend",
5223 " migrate",
5224 " config",
5225 " config-schema",
5226 " plugin-schema",
5227 " plugin-check",
5228 " rule-pack-schema",
5229 "Automation and CI:",
5230 " ci",
5231 " ci-template",
5232 " hooks",
5233 " setup-hooks",
5234 "Runtime coverage:",
5235 " coverage",
5236 " license",
5237 "Reference:",
5238 " schema",
5239 " help",
5240 "Options:",
5241 ];
5242 let mut cursor = 0;
5243 for needle in expected_order {
5244 let Some(offset) = help[cursor..].find(needle) else {
5245 panic!("top-level help missing `{needle}` after byte {cursor}:\n{help}");
5246 };
5247 cursor += offset + needle.len();
5248 }
5249 }
5250
5251 #[test]
5252 fn security_help_hides_globals_rejected_by_security_validator() {
5253 let help = render_security_help(SecurityHelpTarget::Parent);
5254
5255 for long in SECURITY_UNSUPPORTED_GLOBAL_LONGS {
5256 assert!(
5257 !help_contains_long_flag(&help, long),
5258 "security help must hide unsupported --{long}:\n{help}"
5259 );
5260 }
5261
5262 for long in [
5263 "root",
5264 "config",
5265 "format",
5266 "quiet",
5267 "no-cache",
5268 "threads",
5269 "changed-since",
5270 "diff-file",
5271 "diff-stdin",
5272 "workspace",
5273 "changed-workspaces",
5274 "ci",
5275 "fail-on-issues",
5276 "sarif-file",
5277 "summary",
5278 "output-file",
5279 "max-file-size",
5280 "explain",
5281 "surface",
5282 ] {
5283 assert!(
5284 help_contains_long_flag(&help, long),
5285 "security help must keep supported --{long}:\n{help}"
5286 );
5287 }
5288 }
5289
5290 #[test]
5291 fn security_help_detection_covers_subcommand_and_help_alias_forms() {
5292 assert_eq!(
5293 security_help_target(["security", "--help"]),
5294 Some(SecurityHelpTarget::Parent)
5295 );
5296 assert_eq!(
5297 security_help_target(["security", "-h"]),
5298 Some(SecurityHelpTarget::Parent)
5299 );
5300 assert_eq!(
5301 security_help_target(["--format", "json", "security", "--help"]),
5302 Some(SecurityHelpTarget::Parent)
5303 );
5304 assert_eq!(
5305 security_help_target(["help", "security"]),
5306 Some(SecurityHelpTarget::Parent)
5307 );
5308 assert_eq!(
5309 security_help_target(["security", "survivors", "--help"]),
5310 Some(SecurityHelpTarget::Survivors)
5311 );
5312 assert_eq!(
5313 security_help_target(["security", "survivors", "-h"]),
5314 Some(SecurityHelpTarget::Survivors)
5315 );
5316 assert_eq!(
5317 security_help_target(["help", "security", "survivors"]),
5318 Some(SecurityHelpTarget::Survivors)
5319 );
5320 assert_eq!(
5321 security_help_target(["security", "blind-spots", "--help"]),
5322 Some(SecurityHelpTarget::BlindSpots)
5323 );
5324 assert_eq!(
5325 security_help_target(["help", "security", "blind-spots"]),
5326 Some(SecurityHelpTarget::BlindSpots)
5327 );
5328 assert_eq!(security_help_target(["health", "--help"]), None);
5329 assert_eq!(security_help_target(["help", "health"]), None);
5330 }
5331
5332 #[test]
5333 fn security_unsupported_global_validator_matches_hidden_help_contract() {
5334 for (argv, expected) in [
5335 (vec!["fallow", "security", "--performance"], "--performance"),
5336 (
5337 vec!["fallow", "security", "--baseline", "base.json"],
5338 "--baseline",
5339 ),
5340 (
5341 vec!["fallow", "security", "--dupes-mode", "weak"],
5342 "--dupes-mode",
5343 ),
5344 ] {
5345 let cli = Cli::try_parse_from(argv).expect("security global parses before validation");
5346 assert_eq!(unsupported_security_global(&cli), Some(expected));
5347 }
5348
5349 let explain = Cli::try_parse_from(["fallow", "security", "--explain"])
5350 .expect("security --explain parses");
5351 assert_eq!(unsupported_security_global(&explain), None);
5352 }
5353
5354 #[test]
5355 fn programmatic_common_options_track_analysis_affecting_cli_globals() {
5356 use clap::CommandFactory;
5357
5358 let cli_flags: std::collections::BTreeSet<String> = Cli::command()
5359 .get_arguments()
5360 .filter(|arg| arg.is_global_set())
5361 .filter_map(|arg| arg.get_long().map(str::to_owned))
5362 .filter(|name| {
5363 matches!(
5364 name.as_str(),
5365 "root"
5366 | "config"
5367 | "allow-remote-extends"
5368 | "no-cache"
5369 | "threads"
5370 | "changed-since"
5371 | "diff-file"
5372 | "production"
5373 | "workspace"
5374 | "changed-workspaces"
5375 | "explain"
5376 )
5377 })
5378 .collect();
5379 let programmatic_flags: std::collections::BTreeSet<String> =
5380 fallow_api::COMMON_ANALYSIS_OPTION_FLAGS
5381 .iter()
5382 .map(|flag| (*flag).to_owned())
5383 .collect();
5384
5385 assert_eq!(programmatic_flags, cli_flags);
5386 }
5387
5388 #[test]
5389 fn dead_code_registry_filter_flags_are_exposed_by_clap() {
5390 use clap::CommandFactory;
5391
5392 let cli = Cli::command();
5393 let dead_code = cli
5394 .get_subcommands()
5395 .find(|command| command.get_name() == "dead-code")
5396 .expect("dead-code subcommand is registered");
5397 let cli_flags: std::collections::BTreeSet<String> = dead_code
5398 .get_arguments()
5399 .filter_map(|arg| arg.get_long().map(|long| format!("--{long}")))
5400 .collect();
5401
5402 for flag in fallow_types::issue_meta::DEAD_CODE_FILTER_FLAGS.iter() {
5403 assert!(
5404 cli_flags.contains(*flag),
5405 "registry filter flag {flag} is missing from dead-code clap args"
5406 );
5407 }
5408 }
5409
5410 fn help_contains_long_flag(help: &str, long: &str) -> bool {
5411 let flag = format!("--{long}");
5412 help.split(|c: char| c.is_whitespace() || c == ',' || c == '[' || c == ']')
5413 .any(|token| token == flag)
5414 }
5415
5416 fn visit_help(cmd: &mut clap::Command, path: &str, violations: &mut Vec<(String, String)>) {
5417 let help = cmd.render_long_help().to_string();
5418 for line in scan_forbidden(&help) {
5419 violations.push((path.to_owned(), line));
5420 }
5421 let names: Vec<String> = cmd
5422 .get_subcommands()
5423 .map(|sub| sub.get_name().to_owned())
5424 .collect();
5425 for name in names {
5426 if name == "help" {
5427 continue;
5428 }
5429 if let Some(sub) = cmd.find_subcommand_mut(&name) {
5430 let sub_path = format!("{path} {name}");
5431 visit_help(sub, &sub_path, violations);
5432 }
5433 }
5434 }
5435
5436 fn scan_forbidden(s: &str) -> Vec<String> {
5437 let lower = s.to_ascii_lowercase();
5438 let mut out = Vec::new();
5439 for word in ["stub", "placeholder"] {
5440 if let Some(idx) = find_whole_word(&lower, word) {
5441 out.push(extract_line(s, idx));
5442 }
5443 }
5444 if let Some(idx) = lower.find("not yet") {
5445 out.push(extract_line(s, idx));
5446 }
5447 out
5448 }
5449
5450 fn find_whole_word(haystack: &str, word: &str) -> Option<usize> {
5451 let bytes = haystack.as_bytes();
5452 let mut start = 0;
5453 while let Some(rel) = haystack[start..].find(word) {
5454 let abs = start + rel;
5455 let before_ok = abs == 0 || !bytes[abs - 1].is_ascii_alphanumeric();
5456 let after_idx = abs + word.len();
5457 let after_ok = after_idx >= bytes.len() || !bytes[after_idx].is_ascii_alphanumeric();
5458 if before_ok && after_ok {
5459 return Some(abs);
5460 }
5461 start = abs + word.len();
5462 }
5463 None
5464 }
5465
5466 fn extract_line(s: &str, byte_idx: usize) -> String {
5467 let line_start = s[..byte_idx].rfind('\n').map_or(0, |i| i + 1);
5468 let line_end = s[byte_idx..].find('\n').map_or(s.len(), |i| byte_idx + i);
5469 s[line_start..line_end].trim().to_owned()
5470 }
5471
5472 #[test]
5473 fn emit_error_returns_given_exit_code() {
5474 let code = emit_error("test error", 2, fallow_config::OutputFormat::Human);
5475 assert_eq!(code, ExitCode::from(2));
5476 }
5477
5478 fn telemetry_run_for_mode(mode: telemetry::AnalysisMode) -> TelemetryRun {
5479 TelemetryRun {
5480 workflow: telemetry::Workflow::Health,
5481 output: fallow_config::OutputFormat::Json,
5482 quiet: true,
5483 start: std::time::Instant::now(),
5484 context: telemetry::WorkflowContext {
5485 run_scope: telemetry::RunScope::FullProject,
5486 config_shape: telemetry::ConfigShape::Default,
5487 output_destination: telemetry::OutputDestination::Stdout,
5488 analysis_mode: mode,
5489 },
5490 }
5491 }
5492
5493 #[test]
5494 fn fallback_failure_reason_skips_success_and_findings() {
5495 let run = telemetry_run_for_mode(telemetry::AnalysisMode::Static);
5496
5497 assert_eq!(fallback_failure_reason_for(&run, ExitCode::SUCCESS), None);
5498 assert_eq!(fallback_failure_reason_for(&run, ExitCode::from(1)), None);
5499 }
5500
5501 #[test]
5502 fn fallback_failure_reason_classifies_network_auth_and_analysis() {
5503 let static_run = telemetry_run_for_mode(telemetry::AnalysisMode::Static);
5504 let cloud_run = telemetry_run_for_mode(telemetry::AnalysisMode::ProductionCoverage);
5505
5506 assert_eq!(
5507 fallback_failure_reason_for(&static_run, ExitCode::from(api::NETWORK_EXIT_CODE)),
5508 Some(telemetry::FailureReason::Network),
5509 );
5510 assert_eq!(
5511 fallback_failure_reason_for(&static_run, ExitCode::from(12)),
5512 Some(telemetry::FailureReason::Auth),
5513 );
5514 assert_eq!(
5515 fallback_failure_reason_for(&cloud_run, ExitCode::from(3)),
5516 Some(telemetry::FailureReason::Auth),
5517 );
5518 assert_eq!(
5519 fallback_failure_reason_for(&static_run, ExitCode::from(2)),
5520 Some(telemetry::FailureReason::Analysis),
5521 );
5522 }
5523
5524 #[test]
5525 fn bare_coverage_flags_parse_without_subcommand() {
5526 let cli = Cli::try_parse_from([
5527 "fallow",
5528 "--coverage",
5529 "coverage/coverage-final.json",
5530 "--coverage-root",
5531 "/ci/workspace",
5532 ])
5533 .expect("bare combined coverage flags should parse");
5534 assert!(cli.command.is_none());
5535 assert_eq!(
5536 cli.coverage.as_deref(),
5537 Some(std::path::Path::new("coverage/coverage-final.json"))
5538 );
5539 assert_eq!(
5540 cli.coverage_root.as_deref(),
5541 Some(std::path::Path::new("/ci/workspace"))
5542 );
5543 }
5544
5545 #[test]
5546 fn bare_coverage_before_subcommand_is_detectable() {
5547 let cli = Cli::try_parse_from([
5548 "fallow",
5549 "--coverage",
5550 "coverage/coverage-final.json",
5551 "dead-code",
5552 ])
5553 .expect("clap should parse pre-subcommand bare coverage for custom rejection");
5554 assert!(cli.command.is_some());
5555 assert!(cli_has_bare_coverage_input(&cli));
5556 let message = bare_coverage_subcommand_error_message();
5557 assert!(message.contains("bare combined-mode flags"));
5558 assert!(message.contains("fallow health --coverage <coverage-final.json>"));
5559 }
5560
5561 #[test]
5562 fn subcommand_coverage_flag_keeps_regular_clap_error() {
5563 let Err(err) = Cli::try_parse_from(["fallow", "dead-code", "--coverage"]) else {
5564 panic!("dead-code --coverage should fail to parse");
5565 };
5566 assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
5567 }
5568
5569 #[test]
5570 fn format_parsing_covers_all_variants() {
5571 assert!(matches!(parse_format_arg("json"), Some(Format::Json)));
5572 assert!(matches!(parse_format_arg("JSON"), Some(Format::Json)));
5573 assert!(matches!(parse_format_arg("human"), Some(Format::Human)));
5574 assert!(matches!(parse_format_arg("sarif"), Some(Format::Sarif)));
5575 assert!(matches!(parse_format_arg("compact"), Some(Format::Compact)));
5576 assert!(matches!(
5577 parse_format_arg("markdown"),
5578 Some(Format::Markdown)
5579 ));
5580 assert!(matches!(parse_format_arg("md"), Some(Format::Markdown)));
5581 assert!(matches!(
5582 parse_format_arg("codeclimate"),
5583 Some(Format::CodeClimate)
5584 ));
5585 assert!(matches!(
5586 parse_format_arg("gitlab-codequality"),
5587 Some(Format::CodeClimate)
5588 ));
5589 assert!(matches!(
5590 parse_format_arg("gitlab-code-quality"),
5591 Some(Format::CodeClimate)
5592 ));
5593 assert!(matches!(
5594 parse_format_arg("pr-comment-github"),
5595 Some(Format::PrCommentGithub)
5596 ));
5597 assert!(matches!(
5598 parse_format_arg("pr-comment-gitlab"),
5599 Some(Format::PrCommentGitlab)
5600 ));
5601 assert!(matches!(
5602 parse_format_arg("review-github"),
5603 Some(Format::ReviewGithub)
5604 ));
5605 assert!(matches!(
5606 parse_format_arg("review-gitlab"),
5607 Some(Format::ReviewGitlab)
5608 ));
5609 assert!(matches!(parse_format_arg("badge"), Some(Format::Badge)));
5610 assert!(parse_format_arg("xml").is_none());
5611 assert!(parse_format_arg("").is_none());
5612 }
5613
5614 #[test]
5615 fn quiet_parsing_logic() {
5616 let parse = |s: &str| -> bool { s == "1" || s.eq_ignore_ascii_case("true") };
5617 assert!(parse("1"));
5618 assert!(parse("true"));
5619 assert!(parse("TRUE"));
5620 assert!(parse("True"));
5621 assert!(!parse("0"));
5622 assert!(!parse("false"));
5623 assert!(!parse("yes"));
5624 }
5625
5626 #[test]
5627 fn tracing_filter_defaults_to_warn_without_env() {
5628 assert_eq!(build_tracing_filter(None).to_string(), "warn");
5629 }
5630
5631 #[test]
5632 fn tracing_filter_respects_explicit_env_directives() {
5633 assert_eq!(build_tracing_filter(Some("info")).to_string(), "info");
5634 }
5635
5636 #[test]
5637 fn tracing_filter_treats_empty_env_as_off() {
5638 assert_eq!(build_tracing_filter(Some("")).to_string(), "off");
5639 assert_eq!(build_tracing_filter(Some(" ")).to_string(), "off");
5640 }
5641}