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