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::path::{Path, PathBuf};
16use std::process::ExitCode;
17
18use clap::{Parser, Subcommand};
19
20mod api;
21#[cfg(test)]
22mod architecture_boundaries;
23mod audit;
24mod audit_brief;
25mod audit_decision_surface;
26mod audit_focus;
27mod audit_walkthrough;
28mod base_worktree;
29mod walkthrough_state;
30use fallow_engine::baseline;
31mod cache_notice;
32mod check;
33mod ci;
34mod ci_template;
35mod cli_format;
36mod cli_hooks;
37mod cli_impact;
38mod cli_production;
39mod cli_report;
40mod cli_startup;
41pub use fallow_engine::codeowners;
42mod combined;
43mod config;
44mod coverage;
45mod dupes;
46pub mod explain;
47mod fix;
48mod flags;
49mod guard;
50mod health;
51mod impact;
52mod init;
53mod inspect;
54mod license;
55mod list;
56mod migrate;
57mod onboarding;
58#[cfg(test)]
59mod output_envelope;
60mod output_runtime;
61mod path_util;
62mod plugin_check;
63mod rayon_pool;
64mod regression;
65pub mod report;
66mod rule_pack;
67mod runtime_support;
68mod schema;
69mod security;
70mod security_help;
71mod setup_hooks;
72mod signal;
73mod suppressions;
74mod task_matrix;
75mod telemetry;
76mod trace_chain;
77mod update_check;
78use fallow_engine::validate;
79use fallow_engine::vital_signs;
80mod cli_telemetry;
81mod watch;
82
83use check::{CheckOptions, IssueFilters, TraceOptions};
84pub mod error;
86#[cfg(test)]
87use cli_format::parse_format_arg;
88use cli_format::{Format, FormatConfig, apply_ci_defaults};
89use cli_hooks::{HooksCli, run_hooks_command};
90use cli_impact::{ImpactCli, ImpactCrossRepoOpts, ImpactSortCli, dispatch_impact};
91use cli_production::{ProductionModes, resolve_production_modes};
92#[cfg(test)]
93use cli_startup::build_tracing_filter;
94use cli_startup::{
95 bare_coverage_subcommand_error_message, cli_has_bare_coverage_input, parse_cli_args,
96 run_pre_dispatch_checks, setup_tracing, validate_inputs,
97};
98#[cfg(test)]
99use cli_telemetry::TelemetryRun;
100#[cfg(test)]
101use cli_telemetry::{fallback_failure_reason_for, telemetry_workflow_for_command};
102use cli_telemetry::{record_run_epilogue, start_telemetry_run};
103use dupes::{DupesMode, DupesOptions};
104use error::emit_error;
105use health::{HealthOptions, SortBy};
106use list::ListOptions;
107pub use runtime_support::{AnalysisKind, GroupBy};
108pub(crate) use runtime_support::{
109 ConfigLoadOptions, LoadConfigArgs, build_ownership_resolver, load_config,
110 load_config_for_analysis,
111};
112#[cfg(test)]
113use security_help::{SECURITY_UNSUPPORTED_GLOBAL_LONGS, SecurityHelpTarget};
114use security_help::{render_security_help, security_help_target};
115
116const DEFAULT_MIN_INVOCATIONS_HOT: u64 = 100;
117
118const TOP_LEVEL_HELP_TEMPLATE: &str =
119 "{about-with-newline}\n{usage-heading} {usage}{after-help}\n\nOptions:\n{options}";
120
121const TOP_LEVEL_AFTER_HELP: &str = "\
122Analysis:
123 dead-code Analyze unused code, dependency hygiene, and architecture cycles
124 dupes Find copy-paste and structural code duplication
125 health Analyze complexity, maintainability, hotspots, and coverage gaps
126 flags Detect feature flag usage patterns
127 security Surface local security candidates for agent verification (opt-in)
128 audit Review changed files for dead code, complexity, duplication, and styling
129
130Workflow:
131 watch Re-run analysis as files change
132 fix Auto-fix safe unused-code findings
133
134Project inspection:
135 list List discovered files, entry points, plugins, boundaries, and workspaces
136 inspect Inspect one file or exported symbol as a bundled evidence query
137 workspaces Show monorepo workspace discovery diagnostics
138 explain Explain one issue type without running analysis
139 suppressions List active fallow-ignore suppression markers
140 impact Show what fallow has done for you (opt-in, local-only)
141
142Setup and configuration:
143 init Create a fallow config, optionally with a Git hook
144 recommend Recommend a project-tailored config for an agent to author
145 migrate Migrate knip, jscpd, or stylelint config to fallow
146 config Show the resolved config and loaded config file
147 config-schema Print the fallow config JSON Schema
148 plugin-schema Print the external plugin JSON Schema
149 plugin-check Dry-run external plugins and report what they seed
150 rule-pack-schema Print the rule pack JSON Schema
151
152Automation and CI:
153 ci Build PR/MR feedback envelopes
154 ci-template Print or vendor CI integration templates
155 report Re-render a saved --format json results file (GitHub formats)
156 hooks Install or remove fallow-managed Git and agent hooks
157 setup-hooks Legacy agent-hook installer
158
159Runtime coverage:
160 coverage Set up or analyze runtime coverage data
161 license Manage the paid-feature license
162 telemetry Manage opt-in product telemetry
163
164Reference:
165 schema Dump the CLI interface as machine-readable JSON
166 help Print this message or the help of a command
167
168When no command is given, fallow runs dead-code + dupes + health together.
169Use --only/--skip to select specific analyses.
170
171When the agent is about to...
172 delete an \"unused\" export or file fallow dead-code --trace <file>:<export>
173 delete an \"unused\" dependency fallow dead-code --trace-dependency <name>
174 commit or open a PR fallow audit --base <ref>
175 prioritize refactoring fallow health --hotspots --targets
176 ask who owns code fallow health --ownership
177 check untested-but-reachable code fallow health --coverage-gaps
178 consolidate duplication fallow dupes --trace dup:<fingerprint>
179 find feature flags fallow flags
180 check architecture rules before editing fallow guard <files>
181 surface security candidates fallow security
182 inspect a target before editing fallow inspect --file <path>
183 understand a finding fallow explain <issue-type>
184 scope a monorepo --workspace <glob> / --changed-workspaces <ref>";
185
186#[derive(Parser)]
187#[command(
188 name = "fallow",
189 about = "Codebase analyzer for TypeScript/JavaScript: unused code, circular dependencies, code duplication, complexity hotspots, and architecture boundary violations",
190 version,
191 disable_version_flag = true,
192 help_template = TOP_LEVEL_HELP_TEMPLATE,
193 after_help = TOP_LEVEL_AFTER_HELP
194)]
195struct Cli {
196 #[command(subcommand)]
197 command: Option<Command>,
198
199 #[arg(
203 short = 'v',
204 visible_short_alias = 'V',
205 long = "version",
206 action = clap::ArgAction::Version
207 )]
208 version: Option<bool>,
209
210 #[arg(short, long, global = true)]
212 root: Option<PathBuf>,
213
214 #[arg(short, long, global = true)]
216 config: Option<PathBuf>,
217
218 #[arg(long, global = true)]
220 allow_remote_extends: bool,
221
222 #[arg(
224 short,
225 long,
226 visible_alias = "output",
227 global = true,
228 default_value = "human"
229 )]
230 format: Format,
231
232 #[arg(short, long, global = true)]
234 quiet: bool,
235
236 #[arg(long, global = true)]
238 no_cache: bool,
239
240 #[arg(long, global = true)]
242 threads: Option<usize>,
243
244 #[arg(long, visible_alias = "base", global = true)]
246 changed_since: Option<String>,
247
248 #[arg(long = "diff-file", value_name = "PATH", global = true)]
253 diff_file: Option<PathBuf>,
254
255 #[arg(long = "diff-stdin", global = true)]
258 diff_stdin: bool,
259
260 #[arg(long = "churn-file", value_name = "PATH", global = true)]
267 churn_file: Option<PathBuf>,
268
269 #[arg(long = "max-file-size", value_name = "MB", global = true)]
276 max_file_size: Option<u32>,
277
278 #[arg(long, global = true)]
280 baseline: Option<PathBuf>,
281
282 #[arg(long, global = true, value_name = "RUN_ID", hide = true)]
288 parent_run: Option<String>,
289
290 #[arg(long, global = true)]
292 save_baseline: Option<PathBuf>,
293
294 #[arg(long, global = true)]
297 production: bool,
298
299 #[arg(long = "no-production", global = true, conflicts_with = "production")]
303 no_production: bool,
304
305 #[arg(long = "production-dead-code")]
307 production_dead_code: bool,
308
309 #[arg(long = "production-health")]
311 production_health: bool,
312
313 #[arg(long = "production-dupes")]
315 production_dupes: bool,
316
317 #[arg(short, long, global = true, value_delimiter = ',')]
321 workspace: Option<Vec<String>>,
322
323 #[arg(long, global = true, value_name = "REF")]
326 changed_workspaces: Option<String>,
327
328 #[arg(long, global = true)]
330 group_by: Option<GroupBy>,
331
332 #[arg(long, global = true)]
334 performance: bool,
335
336 #[arg(long, global = true)]
338 explain: bool,
339
340 #[arg(long, global = true)]
342 explain_skipped: bool,
343
344 #[arg(long, global = true)]
346 summary: bool,
347
348 #[arg(long, global = true)]
350 ci: bool,
351
352 #[arg(long, global = true)]
354 fail_on_issues: bool,
355
356 #[arg(long, global = true, value_name = "PATH")]
358 sarif_file: Option<PathBuf>,
359
360 #[arg(short = 'o', long, global = true, value_name = "PATH")]
364 output_file: Option<PathBuf>,
365
366 #[arg(
375 long = "report-path-prefix",
376 visible_alias = "annotations-path-prefix",
377 global = true,
378 value_name = "PREFIX"
379 )]
380 report_path_prefix: Option<String>,
381
382 #[arg(long, global = true)]
384 fail_on_regression: bool,
385
386 #[arg(long, global = true, value_name = "TOLERANCE", default_value = "0")]
388 tolerance: String,
389
390 #[arg(long, global = true, value_name = "PATH")]
392 regression_baseline: Option<PathBuf>,
393
394 #[expect(
396 clippy::option_option,
397 reason = "clap pattern: None=not passed, Some(None)=flag only (write to config), Some(Some(path))=write to file"
398 )]
399 #[arg(long, global = true, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
400 save_regression_baseline: Option<Option<String>>,
401
402 #[arg(long, value_delimiter = ',')]
404 only: Vec<AnalysisKind>,
405
406 #[arg(long, value_delimiter = ',')]
408 skip: Vec<AnalysisKind>,
409
410 #[arg(long = "dupes-mode", global = true)]
412 dupes_mode: Option<DupesMode>,
413
414 #[arg(long = "dupes-threshold", global = true)]
416 dupes_threshold: Option<f64>,
417
418 #[arg(long = "dupes-min-tokens", global = true)]
420 dupes_min_tokens: Option<usize>,
421
422 #[arg(long = "dupes-min-lines", global = true)]
424 dupes_min_lines: Option<usize>,
425
426 #[arg(long = "dupes-min-occurrences", global = true, value_parser = parse_min_occurrences)]
428 dupes_min_occurrences: Option<usize>,
429
430 #[arg(long = "dupes-skip-local", global = true)]
432 dupes_skip_local: bool,
433
434 #[arg(long = "dupes-cross-language", global = true)]
436 dupes_cross_language: bool,
437
438 #[arg(long = "dupes-ignore-imports", global = true)]
441 dupes_ignore_imports: bool,
442
443 #[arg(
446 long = "dupes-no-ignore-imports",
447 global = true,
448 conflicts_with = "dupes_ignore_imports"
449 )]
450 dupes_no_ignore_imports: bool,
451
452 #[arg(long)]
454 score: bool,
455
456 #[arg(long)]
458 trend: bool,
459
460 #[expect(
463 clippy::option_option,
464 reason = "clap pattern: None=not passed, Some(None)=default path, Some(Some(path))=custom path"
465 )]
466 #[arg(long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
467 save_snapshot: Option<Option<String>>,
468
469 #[arg(long, value_name = "PATH")]
472 coverage: Option<PathBuf>,
473
474 #[arg(long = "coverage-root", value_name = "PATH")]
477 coverage_root: Option<PathBuf>,
478
479 #[arg(long, global = true)]
481 include_entry_exports: bool,
482}
483
484#[derive(Subcommand)]
485enum Command {
486 #[command(name = "dead-code", alias = "check")]
488 Check {
489 #[arg(long)]
491 unused_files: bool,
492
493 #[arg(long)]
495 unused_exports: bool,
496
497 #[arg(long)]
499 unused_deps: bool,
500
501 #[arg(long)]
503 unused_types: bool,
504
505 #[arg(long)]
507 private_type_leaks: bool,
508
509 #[arg(long)]
511 unused_enum_members: bool,
512
513 #[arg(long)]
515 unused_class_members: bool,
516
517 #[arg(long)]
519 unused_store_members: bool,
520
521 #[arg(long)]
523 unprovided_injects: bool,
524
525 #[arg(long)]
527 unrendered_components: bool,
528
529 #[arg(long)]
531 unused_component_props: bool,
532
533 #[arg(long)]
535 unused_component_emits: bool,
536
537 #[arg(long)]
539 unused_component_inputs: bool,
540
541 #[arg(long)]
543 unused_component_outputs: bool,
544
545 #[arg(long)]
547 unused_svelte_events: bool,
548
549 #[arg(long)]
551 unused_server_actions: bool,
552
553 #[arg(long)]
555 unused_load_data_keys: bool,
556
557 #[arg(long)]
559 unresolved_imports: bool,
560
561 #[arg(long)]
563 unlisted_deps: bool,
564
565 #[arg(long)]
567 duplicate_exports: bool,
568
569 #[arg(long)]
571 circular_deps: bool,
572
573 #[arg(long)]
575 re_export_cycles: bool,
576
577 #[arg(long)]
579 boundary_violations: bool,
580
581 #[arg(long)]
583 policy_violations: bool,
584
585 #[arg(long)]
587 stale_suppressions: bool,
588
589 #[arg(long)]
591 unused_catalog_entries: bool,
592
593 #[arg(long)]
595 empty_catalog_groups: bool,
596
597 #[arg(long)]
599 unresolved_catalog_references: bool,
600
601 #[arg(long)]
603 unused_dependency_overrides: bool,
604
605 #[arg(long)]
607 misconfigured_dependency_overrides: bool,
608
609 #[arg(long)]
611 include_dupes: bool,
612
613 #[arg(long, value_name = "FILE:EXPORT")]
615 trace: Option<String>,
616
617 #[arg(long, value_name = "PATH")]
619 trace_file: Option<String>,
620
621 #[arg(long, value_name = "PACKAGE")]
623 trace_dependency: Option<String>,
624
625 #[arg(long, value_name = "PATH")]
629 impact_closure: Option<String>,
630
631 #[arg(long)]
633 top: Option<usize>,
634
635 #[arg(long, value_name = "PATH")]
639 file: Vec<std::path::PathBuf>,
640 },
641
642 Watch {
644 #[arg(long)]
646 no_clear: bool,
647 },
648
649 Inspect {
651 #[arg(
653 long,
654 value_name = "PATH",
655 conflicts_with = "symbol",
656 required_unless_present = "symbol"
657 )]
658 file: Option<String>,
659
660 #[arg(long, value_name = "FILE:EXPORT", conflicts_with = "file")]
662 symbol: Option<String>,
663
664 #[arg(long)]
669 symbol_chain: bool,
670
671 #[arg(long)]
674 churn: bool,
675 },
676
677 Trace {
686 #[arg(value_name = "FILE:SYMBOL")]
688 symbol: String,
689
690 #[arg(long)]
693 callers: bool,
694
695 #[arg(long)]
698 callees: bool,
699
700 #[arg(long, value_name = "N")]
703 depth: Option<u32>,
704 },
705
706 Fix {
721 #[arg(long)]
723 dry_run: bool,
724
725 #[arg(long, alias = "force")]
727 yes: bool,
728
729 #[arg(long)]
736 no_create_config: bool,
737 },
738
739 Init {
748 #[arg(long)]
750 toml: bool,
751
752 #[arg(long, conflicts_with_all = ["toml", "hooks", "branch"])]
754 agents: bool,
755
756 #[arg(long)]
760 hooks: bool,
761
762 #[arg(long, requires = "hooks")]
764 branch: Option<String>,
765
766 #[arg(long, conflicts_with_all = ["toml", "agents", "hooks", "branch"])]
770 decline: bool,
771 },
772
773 Hooks {
780 #[command(subcommand)]
781 subcommand: HooksCli,
782 },
783
784 Ci {
786 #[command(subcommand)]
787 subcommand: CiCli,
788 },
789
790 ConfigSchema,
792
793 PluginSchema,
795
796 PluginCheck,
798
799 RulePackSchema,
801
802 RulePack {
804 #[command(subcommand)]
805 subcommand: RulePackCli,
806 },
807
808 Guard {
810 #[arg(required = true, num_args = 1..)]
812 files: Vec<String>,
813 },
814
815 Config {
833 #[arg(long)]
835 path: bool,
836 },
837
838 Recommend,
846
847 List {
849 #[arg(long)]
851 entry_points: bool,
852
853 #[arg(long)]
855 files: bool,
856
857 #[arg(long)]
859 plugins: bool,
860
861 #[arg(long)]
863 boundaries: bool,
864
865 #[arg(long)]
869 workspaces: bool,
870 },
871
872 Workspaces,
878
879 Dupes {
881 #[arg(long)]
884 mode: Option<DupesMode>,
885
886 #[arg(long)]
889 min_tokens: Option<usize>,
890
891 #[arg(long)]
894 min_lines: Option<usize>,
895
896 #[arg(long, value_parser = parse_min_occurrences)]
901 min_occurrences: Option<usize>,
902
903 #[arg(long)]
906 threshold: Option<f64>,
907
908 #[arg(long)]
910 skip_local: bool,
911
912 #[arg(long)]
914 cross_language: bool,
915
916 #[arg(long)]
920 ignore_imports: bool,
921
922 #[arg(long, conflicts_with = "ignore_imports")]
925 no_ignore_imports: bool,
926
927 #[arg(long)]
930 top: Option<usize>,
931
932 #[arg(long, value_name = "FILE:LINE")]
934 trace: Option<String>,
935 },
936
937 Health {
943 #[arg(long)]
945 max_cyclomatic: Option<u16>,
946
947 #[arg(long)]
949 max_cognitive: Option<u16>,
950
951 #[arg(long)]
955 max_crap: Option<f64>,
956
957 #[arg(long)]
959 top: Option<usize>,
960
961 #[arg(long, default_value = "cyclomatic")]
963 sort: SortBy,
964
965 #[arg(long)]
968 complexity: bool,
969
970 #[arg(long)]
977 complexity_breakdown: bool,
978
979 #[arg(long)]
984 file_scores: bool,
985
986 #[arg(long)]
989 coverage_gaps: bool,
990
991 #[arg(long)]
994 hotspots: bool,
995
996 #[arg(long)]
1000 ownership: bool,
1001
1002 #[arg(long, value_name = "MODE", value_enum)]
1007 ownership_emails: Option<EmailModeArg>,
1008
1009 #[arg(long)]
1012 targets: bool,
1013
1014 #[arg(long)]
1019 css: bool,
1020
1021 #[arg(long, value_enum)]
1024 effort: Option<EffortFilter>,
1025
1026 #[arg(long)]
1029 score: bool,
1030
1031 #[arg(long, value_name = "N")]
1040 min_score: Option<f64>,
1041
1042 #[arg(long, value_name = "LEVEL", value_enum)]
1046 min_severity: Option<HealthSeverityCli>,
1047
1048 #[arg(long)]
1052 report_only: bool,
1053
1054 #[arg(long, value_name = "DURATION")]
1057 since: Option<String>,
1058
1059 #[arg(long, value_name = "N")]
1061 min_commits: Option<u32>,
1062
1063 #[expect(
1067 clippy::option_option,
1068 reason = "clap pattern: None=not passed, Some(None)=flag only, Some(Some(path))=with value"
1069 )]
1070 #[arg(long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
1071 save_snapshot: Option<Option<String>>,
1072
1073 #[arg(long)]
1077 trend: bool,
1078
1079 #[arg(long, value_name = "PATH")]
1088 coverage: Option<PathBuf>,
1089
1090 #[arg(long, value_name = "PATH")]
1096 coverage_root: Option<PathBuf>,
1097
1098 #[arg(long, value_name = "PATH")]
1102 runtime_coverage: Option<PathBuf>,
1103
1104 #[arg(long, default_value_t = 100)]
1106 min_invocations_hot: u64,
1107
1108 #[arg(long, value_name = "N")]
1114 min_observation_volume: Option<u32>,
1115
1116 #[arg(long, value_name = "RATIO")]
1121 low_traffic_threshold: Option<f64>,
1122 },
1123
1124 Flags {
1131 #[arg(long)]
1133 top: Option<usize>,
1134 },
1135
1136 Suppressions {
1146 #[arg(long, value_name = "PATH")]
1148 file: Vec<std::path::PathBuf>,
1149 },
1150
1151 Explain {
1157 #[arg(required = true, num_args = 1.., value_name = "ISSUE_TYPE")]
1159 issue_type: Vec<String>,
1160 },
1161
1162 #[command(visible_alias = "review")]
1187 Audit {
1188 #[arg(long = "production-dead-code")]
1190 production_dead_code: bool,
1191
1192 #[arg(long = "production-health")]
1194 production_health: bool,
1195
1196 #[arg(long = "production-dupes")]
1198 production_dupes: bool,
1199
1200 #[arg(long)]
1203 dead_code_baseline: Option<PathBuf>,
1204
1205 #[arg(long)]
1208 health_baseline: Option<PathBuf>,
1209
1210 #[arg(long)]
1213 dupes_baseline: Option<PathBuf>,
1214
1215 #[arg(long)]
1219 max_crap: Option<f64>,
1220
1221 #[arg(long, value_name = "PATH")]
1225 coverage: Option<PathBuf>,
1226
1227 #[arg(long, value_name = "PATH")]
1230 coverage_root: Option<PathBuf>,
1231
1232 #[arg(long = "no-css")]
1234 no_css: bool,
1235
1236 #[arg(long)]
1240 css_deep: bool,
1241
1242 #[arg(long = "no-css-deep")]
1244 no_css_deep: bool,
1245
1246 #[arg(long, value_enum)]
1252 gate: Option<AuditGateArg>,
1253
1254 #[arg(long, value_name = "PATH")]
1263 runtime_coverage: Option<PathBuf>,
1264
1265 #[arg(long, default_value_t = 100)]
1268 min_invocations_hot: u64,
1269
1270 #[arg(long, value_name = "MARKER", hide = true)]
1275 gate_marker: Option<String>,
1276
1277 #[arg(long)]
1283 brief: bool,
1284
1285 #[arg(
1290 long,
1291 value_name = "N",
1292 default_value_t = audit_decision_surface::DEFAULT_DECISION_CAP
1293 )]
1294 max_decisions: usize,
1295
1296 #[arg(long, conflicts_with_all = ["walkthrough_file", "walkthrough"])]
1304 walkthrough_guide: bool,
1305
1306 #[arg(long, value_name = "PATH")]
1314 walkthrough_file: Option<PathBuf>,
1315
1316 #[arg(long, conflicts_with_all = ["walkthrough_guide", "walkthrough_file"])]
1322 walkthrough: bool,
1323
1324 #[arg(long, value_name = "PATH")]
1330 mark_viewed: Vec<PathBuf>,
1331
1332 #[arg(long)]
1336 show_cleared: bool,
1337
1338 #[arg(long)]
1344 show_deprioritized: bool,
1345 },
1346
1347 DecisionSurface {
1359 #[arg(
1362 long,
1363 value_name = "N",
1364 default_value_t = audit_decision_surface::DEFAULT_DECISION_CAP
1365 )]
1366 max_decisions: usize,
1367 },
1368
1369 Impact {
1379 #[command(subcommand)]
1380 subcommand: Option<ImpactCli>,
1381 #[arg(long)]
1385 all: bool,
1386 #[arg(long, value_enum, default_value_t = ImpactSortCli::Recent)]
1388 sort: ImpactSortCli,
1389 #[arg(long)]
1392 limit: Option<usize>,
1393 },
1394
1395 Security {
1426 #[command(subcommand)]
1427 subcommand: Option<SecuritySubcommand>,
1428 #[arg(long, value_name = "PATH")]
1433 runtime_coverage: Option<PathBuf>,
1434 #[arg(long, default_value_t = 100)]
1437 min_invocations_hot: u64,
1438 #[arg(long, value_name = "PATH")]
1442 file: Vec<std::path::PathBuf>,
1443 #[arg(long, value_name = "MODE")]
1449 gate: Option<security::SecurityGateArg>,
1450 #[arg(long)]
1452 surface: bool,
1453 },
1454
1455 Report {
1460 #[arg(long, value_name = "PATH")]
1463 from: PathBuf,
1464 },
1465 Schema,
1467
1468 CiTemplate {
1475 #[command(subcommand)]
1476 subcommand: CiTemplateCli,
1477 },
1478
1479 Migrate {
1481 #[arg(long, conflicts_with = "jsonc")]
1483 toml: bool,
1484
1485 #[arg(long)]
1493 jsonc: bool,
1494
1495 #[arg(long)]
1497 dry_run: bool,
1498
1499 #[arg(long, value_name = "PATH")]
1501 from: Option<PathBuf>,
1502 },
1503
1504 License {
1511 #[command(subcommand)]
1512 subcommand: LicenseCli,
1513 },
1514
1515 Telemetry {
1523 #[command(subcommand)]
1524 subcommand: TelemetryCli,
1525 },
1526
1527 Coverage {
1533 #[command(subcommand)]
1534 subcommand: CoverageCli,
1535 },
1536
1537 SetupHooks {
1552 #[arg(long, value_enum)]
1554 agent: Option<setup_hooks::HookAgentArg>,
1555
1556 #[arg(long)]
1558 dry_run: bool,
1559
1560 #[arg(long)]
1563 force: bool,
1564
1565 #[arg(long)]
1567 user: bool,
1568
1569 #[arg(long)]
1571 gitignore_claude: bool,
1572
1573 #[arg(long)]
1577 uninstall: bool,
1578 },
1579}
1580
1581#[derive(Subcommand)]
1582enum SecuritySubcommand {
1583 Survivors {
1585 #[arg(long, value_name = "PATH")]
1587 candidates: PathBuf,
1588 #[arg(long, value_name = "PATH")]
1590 verdicts: PathBuf,
1591 #[arg(long)]
1593 require_verdict_for_each_candidate: bool,
1594 },
1595 #[command(name = "blind-spots")]
1597 BlindSpots {
1598 #[arg(long, value_name = "PATH")]
1600 file: Vec<PathBuf>,
1601 },
1602}
1603
1604#[derive(clap::Subcommand)]
1605enum LicenseCli {
1606 Activate {
1611 #[arg(value_name = "JWT")]
1613 jwt: Option<String>,
1614
1615 #[arg(long, value_name = "PATH")]
1617 from_file: Option<PathBuf>,
1618
1619 #[arg(long, conflicts_with_all = ["jwt", "from_file"])]
1621 stdin: bool,
1622
1623 #[arg(long, requires = "email")]
1630 trial: bool,
1631
1632 #[arg(long, value_name = "ADDR")]
1634 email: Option<String>,
1635 },
1636 Status,
1638 Refresh,
1640 Deactivate,
1642}
1643
1644#[derive(Clone, Copy, clap::Subcommand)]
1645enum TelemetryCli {
1646 Status,
1648 Enable,
1650 Disable,
1652 Inspect {
1654 #[arg(long)]
1656 example: bool,
1657 },
1658}
1659
1660#[derive(clap::Subcommand)]
1661enum CiTemplateCli {
1662 Gitlab {
1664 #[arg(long, value_name = "DIR", num_args = 0..=1, default_missing_value = ".")]
1668 vendor: Option<PathBuf>,
1669
1670 #[arg(long)]
1672 force: bool,
1673 },
1674}
1675
1676#[derive(clap::Subcommand)]
1677enum CoverageCli {
1678 Setup {
1680 #[arg(short = 'y', long)]
1682 yes: bool,
1683
1684 #[arg(long)]
1686 non_interactive: bool,
1687
1688 #[arg(long)]
1690 json: bool,
1691 },
1692 Analyze {
1698 #[arg(long, value_name = "PATH", conflicts_with = "cloud")]
1700 runtime_coverage: Option<PathBuf>,
1701
1702 #[arg(long, visible_alias = "runtime-coverage-cloud")]
1704 cloud: bool,
1705
1706 #[arg(long, value_name = "KEY")]
1708 api_key: Option<String>,
1709
1710 #[arg(long, value_name = "URL")]
1712 api_endpoint: Option<String>,
1713
1714 #[arg(long, value_name = "OWNER/REPO")]
1720 repo: Option<String>,
1721
1722 #[arg(long, value_name = "ID")]
1724 project_id: Option<String>,
1725
1726 #[arg(long, value_name = "DAYS", default_value_t = 30)]
1728 coverage_period: u16,
1729
1730 #[arg(long, value_name = "ENV")]
1732 environment: Option<String>,
1733
1734 #[arg(long, value_name = "SHA")]
1736 commit_sha: Option<String>,
1737
1738 #[arg(long)]
1740 production: bool,
1741
1742 #[arg(long, default_value_t = 100)]
1744 min_invocations_hot: u64,
1745
1746 #[arg(long, value_name = "N")]
1748 min_observation_volume: Option<u32>,
1749
1750 #[arg(long, value_name = "RATIO")]
1752 low_traffic_threshold: Option<f64>,
1753
1754 #[arg(long)]
1756 top: Option<usize>,
1757
1758 #[arg(long)]
1760 blast_radius: bool,
1761
1762 #[arg(long)]
1764 importance: bool,
1765 },
1766 UploadInventory {
1777 #[arg(long, value_name = "KEY")]
1786 api_key: Option<String>,
1787
1788 #[arg(long, value_name = "URL")]
1793 api_endpoint: Option<String>,
1794
1795 #[arg(long, value_name = "PROJECT_ID")]
1800 project_id: Option<String>,
1801
1802 #[arg(long, value_name = "SHA")]
1807 git_sha: Option<String>,
1808
1809 #[arg(long)]
1815 allow_dirty: bool,
1816
1817 #[arg(long, value_name = "GLOB", num_args = 0..)]
1821 exclude_paths: Vec<String>,
1822
1823 #[arg(long, value_name = "PREFIX")]
1836 path_prefix: Option<String>,
1837
1838 #[arg(long)]
1840 dry_run: bool,
1841
1842 #[arg(long)]
1848 with_callers: bool,
1849
1850 #[arg(long)]
1854 ignore_upload_errors: bool,
1855 },
1856 UploadSourceMaps {
1869 #[arg(long, value_name = "PATH", default_value = "dist")]
1871 dir: PathBuf,
1872
1873 #[arg(long, value_name = "GLOB", default_value = "**/*.map")]
1875 include: String,
1876
1877 #[arg(long, value_name = "GLOB", default_value = "**/node_modules/**")]
1881 exclude: Vec<String>,
1882
1883 #[arg(long, value_name = "NAME")]
1887 repo: Option<String>,
1888
1889 #[arg(long, value_name = "SHA")]
1894 git_sha: Option<String>,
1895
1896 #[arg(long, value_name = "URL")]
1898 endpoint: Option<String>,
1899
1900 #[arg(long, value_name = "BOOL", default_value_t = true, action = clap::ArgAction::Set)]
1905 strip_path: bool,
1906
1907 #[arg(long)]
1909 dry_run: bool,
1910
1911 #[arg(long, value_name = "N", default_value_t = 4)]
1913 concurrency: usize,
1914
1915 #[arg(long)]
1917 fail_fast: bool,
1918 },
1919 UploadStaticFindings {
1926 #[arg(long, value_name = "KEY")]
1936 api_key: Option<String>,
1937
1938 #[arg(long, value_name = "URL")]
1943 api_endpoint: Option<String>,
1944
1945 #[arg(long, value_name = "PROJECT_ID")]
1950 project_id: Option<String>,
1951
1952 #[arg(long, value_name = "SHA")]
1957 git_sha: Option<String>,
1958
1959 #[arg(long)]
1965 allow_dirty: bool,
1966
1967 #[arg(long)]
1969 dry_run: bool,
1970
1971 #[arg(long)]
1975 ignore_upload_errors: bool,
1976 },
1977}
1978
1979#[derive(Subcommand)]
1980enum CiCli {
1981 PlanPrComment {
1983 #[arg(long)]
1985 body: PathBuf,
1986
1987 #[arg(long)]
1989 marker_id: String,
1990
1991 #[arg(long)]
1993 clean: bool,
1994
1995 #[arg(long)]
1997 existing_comment_id: Option<String>,
1998
1999 #[arg(long)]
2001 existing_body: Option<PathBuf>,
2002 },
2003
2004 PostPrComment {
2006 #[arg(long, value_enum)]
2008 provider: CiProviderArg,
2009
2010 #[arg(long)]
2012 pr: Option<String>,
2013
2014 #[arg(long)]
2016 mr: Option<String>,
2017
2018 #[arg(long)]
2020 body: PathBuf,
2021
2022 #[arg(long)]
2024 envelope: Option<PathBuf>,
2025
2026 #[arg(long)]
2028 marker_id: String,
2029
2030 #[arg(long)]
2032 clean: bool,
2033
2034 #[arg(long)]
2036 repo: Option<String>,
2037
2038 #[arg(long = "project-id")]
2040 project_id: Option<String>,
2041
2042 #[arg(long = "api-url")]
2044 api_url: Option<String>,
2045
2046 #[arg(long)]
2048 dry_run: bool,
2049 },
2050
2051 PostReview {
2053 #[arg(long, value_enum)]
2055 provider: CiProviderArg,
2056
2057 #[arg(long)]
2059 pr: Option<String>,
2060
2061 #[arg(long)]
2063 mr: Option<String>,
2064
2065 #[arg(long)]
2067 envelope: PathBuf,
2068
2069 #[arg(long)]
2071 repo: Option<String>,
2072
2073 #[arg(long = "project-id")]
2075 project_id: Option<String>,
2076
2077 #[arg(long = "api-url")]
2079 api_url: Option<String>,
2080
2081 #[arg(long)]
2083 dry_run: bool,
2084 },
2085
2086 PostCheckRun {
2088 #[arg(long, value_enum)]
2090 provider: CiProviderArg,
2091
2092 #[arg(long)]
2094 decision: PathBuf,
2095
2096 #[arg(long)]
2098 repo: String,
2099
2100 #[arg(long = "head-sha")]
2102 head_sha: String,
2103
2104 #[arg(long = "api-url")]
2106 api_url: Option<String>,
2107
2108 #[arg(long = "split-gates")]
2110 split_gates: bool,
2111
2112 #[arg(long)]
2114 dry_run: bool,
2115 },
2116
2117 ReconcileReview {
2119 #[arg(long, value_enum)]
2121 provider: CiProviderArg,
2122
2123 #[arg(long)]
2125 pr: Option<String>,
2126
2127 #[arg(long)]
2129 mr: Option<String>,
2130
2131 #[arg(long)]
2133 envelope: PathBuf,
2134
2135 #[arg(long)]
2137 repo: Option<String>,
2138
2139 #[arg(long = "project-id")]
2141 project_id: Option<String>,
2142
2143 #[arg(long = "api-url")]
2145 api_url: Option<String>,
2146
2147 #[arg(long)]
2149 dry_run: bool,
2150 },
2151}
2152
2153#[derive(Subcommand)]
2154enum RulePackCli {
2155 Init {
2157 name: Option<String>,
2159
2160 #[arg(long, default_value = "starter")]
2162 template: String,
2163
2164 #[arg(long, default_value = "rule-packs")]
2166 dir: String,
2167
2168 #[arg(long)]
2170 no_config: bool,
2171 },
2172
2173 List,
2175
2176 Test {
2178 pack: Option<PathBuf>,
2180 },
2181
2182 Schema,
2184}
2185
2186#[derive(Clone, Copy, Debug, clap::ValueEnum)]
2187enum CiProviderArg {
2188 Github,
2189 Gitlab,
2190}
2191
2192#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2194pub enum EffortFilter {
2195 Low,
2196 Medium,
2197 High,
2198}
2199
2200impl EffortFilter {
2201 const fn to_estimate(self) -> fallow_output::EffortEstimate {
2203 match self {
2204 Self::Low => fallow_output::EffortEstimate::Low,
2205 Self::Medium => fallow_output::EffortEstimate::Medium,
2206 Self::High => fallow_output::EffortEstimate::High,
2207 }
2208 }
2209}
2210
2211#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2213pub enum HealthSeverityCli {
2214 Moderate,
2215 High,
2216 Critical,
2217}
2218
2219impl HealthSeverityCli {
2220 const fn to_health_severity(self) -> fallow_output::FindingSeverity {
2222 match self {
2223 Self::Moderate => fallow_output::FindingSeverity::Moderate,
2224 Self::High => fallow_output::FindingSeverity::High,
2225 Self::Critical => fallow_output::FindingSeverity::Critical,
2226 }
2227 }
2228}
2229
2230#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2236pub enum EmailModeArg {
2237 Raw,
2239 Handle,
2241 Anonymized,
2243 #[value(hide = true)]
2245 Hash,
2246}
2247
2248impl EmailModeArg {
2249 const fn to_config(self) -> fallow_config::EmailMode {
2251 match self {
2252 Self::Raw => fallow_config::EmailMode::Raw,
2253 Self::Handle => fallow_config::EmailMode::Handle,
2254 Self::Anonymized => fallow_config::EmailMode::Anonymized,
2255 Self::Hash => fallow_config::EmailMode::Hash,
2256 }
2257 }
2258}
2259
2260#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2262pub enum AuditGateArg {
2263 NewOnly,
2265 All,
2267}
2268
2269impl From<AuditGateArg> for fallow_config::AuditGate {
2270 fn from(value: AuditGateArg) -> Self {
2271 match value {
2272 AuditGateArg::NewOnly => Self::NewOnly,
2273 AuditGateArg::All => Self::All,
2274 }
2275 }
2276}
2277
2278fn parse_min_occurrences(s: &str) -> Result<usize, String> {
2282 let value: usize = s
2283 .parse()
2284 .map_err(|_| format!("`{s}` is not a non-negative integer"))?;
2285 if value < 2 {
2286 return Err(format!(
2287 "must be at least 2 (got {value}); a single occurrence isn't a duplicate"
2288 ));
2289 }
2290 Ok(value)
2291}
2292
2293fn resolve_audit_baseline_path(
2299 root: &std::path::Path,
2300 cli: Option<&std::path::Path>,
2301 config: Option<&str>,
2302) -> Option<PathBuf> {
2303 let path = cli.map(std::path::Path::to_path_buf).or_else(|| {
2304 config.map(|p| {
2305 let path = PathBuf::from(p);
2306 if path_util::is_absolute_path_any_platform(&path) {
2307 path
2308 } else {
2309 root.join(path)
2310 }
2311 })
2312 })?;
2313 if path_util::is_absolute_path_any_platform(&path) {
2314 Some(path)
2315 } else {
2316 Some(root.join(path))
2317 }
2318}
2319
2320fn emit_known_failure(
2321 message: &str,
2322 exit_code: u8,
2323 output: fallow_config::OutputFormat,
2324 reason: telemetry::FailureReason,
2325) -> ExitCode {
2326 telemetry::note_failure_reason(reason);
2327 emit_error(message, exit_code, output)
2328}
2329
2330fn unsupported_security_global(cli: &Cli) -> Option<&'static str> {
2331 if cli.baseline.is_some() {
2332 Some("--baseline")
2333 } else if cli.save_baseline.is_some() {
2334 Some("--save-baseline")
2335 } else if cli.production {
2336 Some("--production")
2337 } else if cli.no_production {
2338 Some("--no-production")
2339 } else if cli.group_by.is_some() {
2340 Some("--group-by")
2341 } else if cli.performance {
2342 Some("--performance")
2343 } else if cli.explain_skipped {
2344 Some("--explain-skipped")
2345 } else if cli.fail_on_regression {
2346 Some("--fail-on-regression")
2347 } else if cli.regression_baseline.is_some() {
2348 Some("--regression-baseline")
2349 } else if cli.save_regression_baseline.is_some() {
2350 Some("--save-regression-baseline")
2351 } else if cli.dupes_mode.is_some() {
2352 Some("--dupes-mode")
2353 } else if cli.dupes_threshold.is_some() {
2354 Some("--dupes-threshold")
2355 } else if cli.dupes_min_tokens.is_some() {
2356 Some("--dupes-min-tokens")
2357 } else if cli.dupes_min_lines.is_some() {
2358 Some("--dupes-min-lines")
2359 } else if cli.dupes_min_occurrences.is_some() {
2360 Some("--dupes-min-occurrences")
2361 } else if cli.dupes_skip_local {
2362 Some("--dupes-skip-local")
2363 } else if cli.dupes_cross_language {
2364 Some("--dupes-cross-language")
2365 } else if cli.dupes_ignore_imports {
2366 Some("--dupes-ignore-imports")
2367 } else if cli.dupes_no_ignore_imports {
2368 Some("--dupes-no-ignore-imports")
2369 } else if cli.include_entry_exports {
2370 Some("--include-entry-exports")
2371 } else {
2372 None
2373 }
2374}
2375
2376struct DispatchContext<'a> {
2377 cli: &'a Cli,
2378 root: &'a std::path::Path,
2379 output: fallow_config::OutputFormat,
2380 quiet: bool,
2381 cli_format_was_explicit: bool,
2382 threads: usize,
2383 tolerance: regression::Tolerance,
2384 save_regression_file: Option<&'a std::path::PathBuf>,
2385 save_to_config: bool,
2386}
2387
2388impl DispatchContext<'_> {
2389 fn ci_defaults(&self) -> (fallow_config::OutputFormat, bool, bool) {
2390 apply_ci_defaults(
2391 self.cli.ci,
2392 self.cli.fail_on_issues,
2393 self.output,
2394 self.quiet,
2395 self.cli_format_was_explicit,
2396 )
2397 }
2398
2399 fn production_modes(
2400 &self,
2401 dead_code: bool,
2402 health: bool,
2403 dupes: bool,
2404 ) -> Result<ProductionModes, ExitCode> {
2405 resolve_production_modes(self.cli, self.root, self.output, dead_code, health, dupes)
2406 }
2407
2408 fn production_for(
2409 &self,
2410 analysis: fallow_config::ProductionAnalysis,
2411 ) -> Result<bool, ExitCode> {
2412 self.production_modes(false, false, false)
2413 .map(|modes| modes.for_analysis(analysis))
2414 }
2415
2416 fn regression_opts(&self, scoped: bool) -> regression::RegressionOpts<'_> {
2417 regression::RegressionOpts {
2418 fail_on_regression: self.cli.fail_on_regression,
2419 tolerance: self.tolerance,
2420 regression_baseline_file: self.cli.regression_baseline.as_deref(),
2421 save_target: if let Some(path) = self.save_regression_file {
2422 regression::SaveRegressionTarget::File(path)
2423 } else if self.save_to_config {
2424 regression::SaveRegressionTarget::Config
2425 } else {
2426 regression::SaveRegressionTarget::None
2427 },
2428 scoped,
2429 quiet: self.quiet,
2430 output: self.output,
2431 }
2432 }
2433}
2434
2435#[cfg(unix)]
2450fn signal_test_helper() -> ExitCode {
2451 use std::io::Write as _;
2452 use std::process::Command;
2453
2454 if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER_GRACEFUL").is_some() {
2455 signal::set_graceful_mode();
2456 }
2457
2458 let mut command = Command::new("sleep");
2459 command.arg("30");
2460 let child = match signal::ScopedChild::spawn(&mut command) {
2461 Ok(c) => c,
2462 Err(err) => {
2463 let _ = writeln!(std::io::stderr(), "spawn sleep failed: {err}");
2464 return ExitCode::from(2);
2465 }
2466 };
2467 let pid = child.id();
2468 let stdout = std::io::stdout();
2469 let mut lock = stdout.lock();
2470 let _ = writeln!(lock, "{pid}");
2471 let _ = lock.flush();
2472 drop(lock);
2473 let _ = child.wait_with_output();
2474 if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER_GRACEFUL").is_some() {
2475 return ExitCode::SUCCESS;
2476 }
2477 std::thread::sleep(std::time::Duration::from_secs(5));
2478 ExitCode::SUCCESS
2479}
2480
2481#[cfg(not(unix))]
2482fn signal_test_helper() -> ExitCode {
2483 ExitCode::from(2)
2484}
2485
2486fn install_spawn_hooks() {
2487 fallow_engine::churn::set_spawn_hook(signal::scoped_child::output);
2488 fallow_engine::changed_files::set_spawn_hook(signal::scoped_child::output);
2489}
2490
2491fn install_signal_handlers() {
2492 if let Err(err) = signal::install_handlers() {
2493 use std::io::Write as _;
2494 let stderr = std::io::stderr();
2495 let mut lock = stderr.lock();
2496 let _ = writeln!(lock, "fallow: failed to install signal handlers: {err}");
2497 }
2498}
2499
2500fn redirect_report_to_file(
2505 path: &std::path::Path,
2506 output: fallow_config::OutputFormat,
2507) -> Result<(), ExitCode> {
2508 if let Some(parent) = path.parent()
2509 && !parent.as_os_str().is_empty()
2510 && let Err(e) = std::fs::create_dir_all(parent)
2511 {
2512 return Err(emit_error(
2513 &format!(
2514 "failed to create {} for --output-file: {e}",
2515 parent.display()
2516 ),
2517 2,
2518 output,
2519 ));
2520 }
2521 match std::fs::File::create(path) {
2522 Ok(file) => {
2523 report::sink::set_file_sink(file);
2524 colored::control::set_override(false);
2525 Ok(())
2526 }
2527 Err(e) => Err(emit_error(
2528 &format!("failed to open {} for --output-file: {e}", path.display()),
2529 2,
2530 output,
2531 )),
2532 }
2533}
2534
2535fn finalize_report_file(
2538 path: &std::path::Path,
2539 quiet: bool,
2540 output: fallow_config::OutputFormat,
2541) -> Result<(), ExitCode> {
2542 if let Err(e) = report::sink::flush() {
2543 return Err(emit_error(
2544 &format!("failed to write {}: {e}", path.display()),
2545 2,
2546 output,
2547 ));
2548 }
2549 if !quiet && report::sink::wrote() {
2553 eprintln!("Report written to {}", path.display());
2554 }
2555 Ok(())
2556}
2557
2558pub fn run() -> ExitCode {
2563 install_signal_handlers();
2564 install_spawn_hooks();
2565
2566 if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER").is_some() {
2567 return signal_test_helper();
2568 }
2569
2570 let (mut cli, fmt) = match parse_cli_args() {
2571 Ok(parsed) => parsed,
2572 Err(code) => return code,
2573 };
2574
2575 if let Some(code) = run_schema_command_if_requested(&cli) {
2576 return code;
2577 }
2578
2579 if let Some(code) = run_telemetry_command_if_requested(&mut cli, fmt.output) {
2580 return code;
2581 }
2582 let telemetry_run = start_telemetry_run(&cli, &fmt);
2583
2584 let (root, threads) = match validate_inputs(&cli, fmt.output) {
2585 Ok(v) => v,
2586 Err(code) => {
2587 return record_run_epilogue(telemetry_run, code, None, cli.parent_run.as_deref());
2588 }
2589 };
2590
2591 let FormatConfig {
2592 output,
2593 quiet,
2594 cli_format_was_explicit,
2595 } = fmt;
2596
2597 let tolerance = match run_pre_dispatch_checks(&cli, &root, output, quiet, telemetry_run) {
2598 Ok(tolerance) => tolerance,
2599 Err(code) => return code,
2600 };
2601
2602 let (save_regression_file, save_to_config) = regression_save_targets(&cli);
2603
2604 let command = cli.command.take();
2605 let dispatch = DispatchContext {
2606 cli: &cli,
2607 root: &root,
2608 output,
2609 quiet,
2610 cli_format_was_explicit,
2611 threads,
2612 tolerance,
2613 save_regression_file: save_regression_file.as_ref(),
2614 save_to_config,
2615 };
2616 let exit_code = match dispatch_and_finalize(&dispatch, command) {
2617 Ok(code) => code,
2618 Err(code) => return code,
2619 };
2620 record_run_epilogue(telemetry_run, exit_code, None, cli.parent_run.as_deref())
2621}
2622
2623fn dispatch_and_finalize(
2627 dispatch: &DispatchContext<'_>,
2628 command: Option<Command>,
2629) -> Result<ExitCode, ExitCode> {
2630 let cli = dispatch.cli;
2631 let output = dispatch.output;
2632 let quiet = dispatch.quiet;
2633
2634 if let Some(path) = cli.output_file.as_deref()
2637 && let Err(code) = redirect_report_to_file(path, output)
2638 {
2639 return Err(code);
2640 }
2641
2642 let exit_code = if command.is_some() && cli_has_bare_coverage_input(cli) {
2643 emit_error(bare_coverage_subcommand_error_message(), 2, output)
2644 } else {
2645 match command {
2646 None => dispatch_bare_command(dispatch),
2647 Some(cmd) => dispatch_subcommand(cmd, dispatch),
2648 }
2649 };
2650
2651 if let Some(path) = cli.output_file.as_deref()
2652 && let Err(code) = finalize_report_file(path, quiet, output)
2653 {
2654 return Err(code);
2655 }
2656 Ok(exit_code)
2657}
2658
2659fn run_telemetry_command_if_requested(
2660 cli: &mut Cli,
2661 output: fallow_config::OutputFormat,
2662) -> Option<ExitCode> {
2663 if matches!(cli.command, Some(Command::Telemetry { .. }))
2664 && let Some(Command::Telemetry { subcommand }) = cli.command.take()
2665 {
2666 return Some(telemetry::run(map_telemetry_subcommand(subcommand), output));
2667 }
2668 None
2669}
2670
2671fn run_schema_command_if_requested(cli: &Cli) -> Option<ExitCode> {
2672 match cli.command {
2673 Some(Command::Schema) => Some(schema::run_schema()),
2674 Some(Command::ConfigSchema) => Some(init::run_config_schema()),
2675 Some(Command::PluginSchema) => Some(init::run_plugin_schema()),
2676 Some(Command::RulePackSchema) => Some(init::run_rule_pack_schema()),
2677 _ => None,
2678 }
2679}
2680
2681fn regression_save_targets(cli: &Cli) -> (Option<std::path::PathBuf>, bool) {
2682 let save_file = cli.save_regression_baseline.as_ref().and_then(|opt| {
2683 opt.as_ref()
2684 .filter(|path| !path.is_empty())
2685 .map(std::path::PathBuf::from)
2686 });
2687 let save_to_config = cli.save_regression_baseline.is_some() && save_file.is_none();
2688 (save_file, save_to_config)
2689}
2690
2691fn dispatch_bare_command(dispatch: &DispatchContext<'_>) -> ExitCode {
2692 let cli = dispatch.cli;
2693 let (run_check, run_dupes, run_health) = combined::resolve_analyses(&cli.only, &cli.skip);
2694 let production = match dispatch.production_modes(
2695 cli.production_dead_code,
2696 cli.production_health,
2697 cli.production_dupes,
2698 ) {
2699 Ok(production) => production,
2700 Err(code) => return code,
2701 };
2702 let coverage_inputs = match resolve_health_coverage_inputs(
2703 dispatch,
2704 cli.coverage.as_deref(),
2705 cli.coverage_root.as_deref(),
2706 ) {
2707 Ok(inputs) => inputs,
2708 Err(code) => return code,
2709 };
2710 run_bare_combined(
2711 dispatch,
2712 production,
2713 &coverage_inputs,
2714 BareAnalyses {
2715 run_check,
2716 run_dupes,
2717 run_health,
2718 },
2719 )
2720}
2721
2722#[derive(Clone, Copy)]
2724struct BareAnalyses {
2725 run_check: bool,
2726 run_dupes: bool,
2727 run_health: bool,
2728}
2729
2730fn run_bare_combined(
2733 dispatch: &DispatchContext<'_>,
2734 production: ProductionModes,
2735 coverage_inputs: &ResolvedHealthCoverageInputs,
2736 analyses: BareAnalyses,
2737) -> ExitCode {
2738 let cli = dispatch.cli;
2739 let (output, quiet, fail_on_issues) = dispatch.ci_defaults();
2740 combined::run_combined(&combined::CombinedOptions {
2741 root: dispatch.root,
2742 config_path: &cli.config,
2743 output,
2744 no_cache: cli.no_cache,
2745 threads: dispatch.threads,
2746 quiet,
2747 allow_remote_extends: cli.allow_remote_extends,
2748 fail_on_issues,
2749 sarif_file: cli.sarif_file.as_deref(),
2750 changed_since: cli.changed_since.as_deref(),
2751 churn_file: cli.churn_file.as_deref(),
2752 baseline: cli.baseline.as_deref(),
2753 save_baseline: cli.save_baseline.as_deref(),
2754 production: cli.production,
2755 production_dead_code: Some(production.dead_code),
2756 production_health: Some(production.health),
2757 production_dupes: Some(production.dupes),
2758 workspace: cli.workspace.as_deref(),
2759 changed_workspaces: cli.changed_workspaces.as_deref(),
2760 group_by: cli.group_by,
2761 explain: cli.explain,
2762 explain_skipped: cli.explain_skipped,
2763 performance: cli.performance,
2764 summary: cli.summary,
2765 run_check: analyses.run_check,
2766 run_dupes: analyses.run_dupes,
2767 run_health: analyses.run_health,
2768 dupes_mode: cli.dupes_mode,
2769 dupes_threshold: cli.dupes_threshold,
2770 dupes_min_tokens: cli.dupes_min_tokens,
2771 dupes_min_lines: cli.dupes_min_lines,
2772 dupes_min_occurrences: cli.dupes_min_occurrences,
2773 dupes_skip_local: cli.dupes_skip_local,
2774 dupes_cross_language: cli.dupes_cross_language,
2775 dupes_ignore_imports: resolve_ignore_imports(
2776 cli.dupes_ignore_imports,
2777 cli.dupes_no_ignore_imports,
2778 ),
2779 score: cli.score || cli.trend,
2780 trend: cli.trend,
2781 save_snapshot: cli.save_snapshot.as_ref(),
2782 coverage: coverage_inputs.coverage.as_deref(),
2783 coverage_root: coverage_inputs.coverage_root.as_deref(),
2784 include_entry_exports: cli.include_entry_exports,
2785 regression_opts: dispatch.regression_opts(
2786 cli.changed_since.is_some()
2787 || cli.workspace.is_some()
2788 || cli.changed_workspaces.is_some(),
2789 ),
2790 })
2791}
2792
2793fn dispatch_subcommand(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
2794 let cli = dispatch.cli;
2795 let root = dispatch.root;
2796 let output = dispatch.output;
2797 let quiet = dispatch.quiet;
2798 match command {
2799 check @ Command::Check { .. } => dispatch_check_command(check, dispatch),
2800 Command::Watch { no_clear } => dispatch_watch(dispatch, no_clear),
2801 Command::Inspect {
2802 file,
2803 symbol,
2804 symbol_chain,
2805 churn,
2806 } => dispatch_inspect_command(dispatch, file, symbol, symbol_chain, churn),
2807 Command::Trace {
2808 symbol,
2809 callers,
2810 callees,
2811 depth,
2812 } => dispatch_trace_command(dispatch, symbol, callers, callees, depth),
2813 fix @ Command::Fix { .. } => dispatch_fix_command(&fix, dispatch),
2814 init @ Command::Init { .. } => dispatch_init_command(init, root, quiet),
2815 Command::Hooks { subcommand } => run_hooks_command(root, subcommand, output),
2816 Command::Ci { subcommand } => ci::run(map_ci_subcommand(subcommand), output),
2817 Command::ConfigSchema => init::run_config_schema(),
2818 Command::PluginSchema => init::run_plugin_schema(),
2819 Command::PluginCheck => plugin_check::run_plugin_check(root, output),
2820 Command::RulePackSchema => init::run_rule_pack_schema(),
2821 Command::RulePack { subcommand } => dispatch_rule_pack_command(dispatch, subcommand),
2822 Command::Guard { files } => dispatch_guard_command(dispatch, &files),
2823 Command::CiTemplate { subcommand } => dispatch_ci_template_command(subcommand),
2824 Command::Config { path } => config::run_config_with_options(
2825 root,
2826 cli.config.as_deref(),
2827 path,
2828 output,
2829 quiet,
2830 fallow_config::ConfigLoadOptions {
2831 allow_remote_extends: cli.allow_remote_extends,
2832 },
2833 ),
2834 Command::Recommend => onboarding::run_recommend(root, output),
2835 list @ (Command::Workspaces | Command::List { .. }) => {
2836 dispatch_list_command(&list, dispatch)
2837 }
2838 dupes @ Command::Dupes { .. } => dispatch_dupes_command(dupes, dispatch),
2839 health @ Command::Health { .. } => dispatch_health_command(health, dispatch),
2840 Command::Flags { top } => dispatch_flags_command(dispatch, top),
2841 Command::Suppressions { file } => dispatch_suppressions_command(dispatch, &file),
2842 Command::Explain { issue_type } => explain::run_explain(&issue_type.join(" "), output),
2843 audit @ Command::Audit { .. } => dispatch_audit_command(audit, dispatch),
2844 Command::DecisionSurface { max_decisions } => {
2845 dispatch_decision_surface(dispatch, max_decisions)
2846 }
2847 Command::Impact {
2848 subcommand,
2849 all,
2850 sort,
2851 limit,
2852 } => dispatch_impact(
2853 root,
2854 quiet,
2855 output,
2856 subcommand,
2857 ImpactCrossRepoOpts { all, sort, limit },
2858 ),
2859 security @ Command::Security { .. } => dispatch_security_command(security, dispatch),
2860 Command::Report { from } => cli_report::run_report(&from, output, root),
2861 Command::Schema => unreachable!("handled above"),
2862 migrate @ Command::Migrate { .. } => dispatch_migrate_command(migrate, root),
2863 Command::License { subcommand } => dispatch_license_command(subcommand, output),
2864 Command::Telemetry { .. } => unreachable!("handled before root validation"),
2865 Command::Coverage { subcommand } => dispatch_coverage_command(dispatch, &subcommand),
2866 setup_hooks @ Command::SetupHooks { .. } => {
2867 dispatch_setup_hooks_command(&setup_hooks, dispatch)
2868 }
2869 }
2870}
2871
2872fn dispatch_check_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
2874 let filters = check_issue_filters(&command);
2875 let Command::Check {
2876 include_dupes,
2877 trace,
2878 trace_file,
2879 trace_dependency,
2880 impact_closure,
2881 top,
2882 file,
2883 ..
2884 } = command
2885 else {
2886 unreachable!("check dispatcher only handles check commands");
2887 };
2888
2889 dispatch_check(
2890 dispatch,
2891 &CheckDispatchArgs {
2892 filters,
2893 trace_opts: TraceOptions {
2894 trace_export: trace,
2895 trace_file,
2896 trace_dependency,
2897 impact_closure,
2898 performance: dispatch.cli.performance,
2899 },
2900 include_dupes,
2901 top,
2902 file,
2903 },
2904 )
2905}
2906
2907fn check_issue_filters(command: &Command) -> IssueFilters {
2912 check_issue_filters_framework(command, &check_issue_filters_core(command))
2913}
2914
2915fn check_issue_filters_core(command: &Command) -> IssueFilters {
2918 let Command::Check {
2919 unused_files,
2920 unused_exports,
2921 unused_deps,
2922 unused_types,
2923 private_type_leaks,
2924 unused_enum_members,
2925 unused_class_members,
2926 unresolved_imports,
2927 unlisted_deps,
2928 duplicate_exports,
2929 circular_deps,
2930 re_export_cycles,
2931 boundary_violations,
2932 policy_violations,
2933 stale_suppressions,
2934 ..
2935 } = command
2936 else {
2937 unreachable!("check filter builder only handles check commands");
2938 };
2939
2940 let mut filters = IssueFilters::default();
2941 for (flag, active) in [
2942 ("--unused-files", *unused_files),
2943 ("--unused-exports", *unused_exports),
2944 ("--unused-deps", *unused_deps),
2945 ("--unused-types", *unused_types),
2946 ("--private-type-leaks", *private_type_leaks),
2947 ("--unused-enum-members", *unused_enum_members),
2948 ("--unused-class-members", *unused_class_members),
2949 ("--unresolved-imports", *unresolved_imports),
2950 ("--unlisted-deps", *unlisted_deps),
2951 ("--duplicate-exports", *duplicate_exports),
2952 ("--circular-deps", *circular_deps),
2953 ("--re-export-cycles", *re_export_cycles),
2954 ("--boundary-violations", *boundary_violations),
2955 ("--policy-violations", *policy_violations),
2956 ("--stale-suppressions", *stale_suppressions),
2957 ] {
2958 enable_check_filter(&mut filters, flag, active);
2959 }
2960 filters
2961}
2962
2963fn check_issue_filters_framework(command: &Command, base: &IssueFilters) -> IssueFilters {
2966 let Command::Check {
2967 unused_store_members,
2968 unprovided_injects,
2969 unrendered_components,
2970 unused_component_props,
2971 unused_component_emits,
2972 unused_component_inputs,
2973 unused_component_outputs,
2974 unused_svelte_events,
2975 unused_server_actions,
2976 unused_load_data_keys,
2977 unused_catalog_entries,
2978 empty_catalog_groups,
2979 unresolved_catalog_references,
2980 unused_dependency_overrides,
2981 misconfigured_dependency_overrides,
2982 ..
2983 } = command
2984 else {
2985 unreachable!("check filter builder only handles check commands");
2986 };
2987
2988 let mut filters = base.clone();
2989 for (flag, active) in [
2990 ("--unused-store-members", *unused_store_members),
2991 ("--unprovided-injects", *unprovided_injects),
2992 ("--unrendered-components", *unrendered_components),
2993 ("--unused-component-props", *unused_component_props),
2994 ("--unused-component-emits", *unused_component_emits),
2995 ("--unused-component-inputs", *unused_component_inputs),
2996 ("--unused-component-outputs", *unused_component_outputs),
2997 ("--unused-svelte-events", *unused_svelte_events),
2998 ("--unused-server-actions", *unused_server_actions),
2999 ("--unused-load-data-keys", *unused_load_data_keys),
3000 ("--unused-catalog-entries", *unused_catalog_entries),
3001 ("--empty-catalog-groups", *empty_catalog_groups),
3002 (
3003 "--unresolved-catalog-references",
3004 *unresolved_catalog_references,
3005 ),
3006 (
3007 "--unused-dependency-overrides",
3008 *unused_dependency_overrides,
3009 ),
3010 (
3011 "--misconfigured-dependency-overrides",
3012 *misconfigured_dependency_overrides,
3013 ),
3014 ] {
3015 enable_check_filter(&mut filters, flag, active);
3016 }
3017 filters
3018}
3019
3020fn enable_check_filter(filters: &mut IssueFilters, flag: &str, active: bool) {
3021 if active {
3022 assert!(
3023 filters.enable_cli_filter_flag(flag),
3024 "check command uses unregistered dead-code filter flag {flag}"
3025 );
3026 }
3027}
3028
3029fn dispatch_inspect_command(
3030 dispatch: &DispatchContext<'_>,
3031 file: Option<String>,
3032 symbol: Option<String>,
3033 symbol_chain: bool,
3034 churn: bool,
3035) -> ExitCode {
3036 let target = match (file, symbol) {
3037 (Some(file), None) => inspect::InspectTarget::File { file },
3038 (None, Some(symbol)) => match symbol.rsplit_once(':') {
3039 Some((file, export_name))
3040 if !file.trim().is_empty() && !export_name.trim().is_empty() =>
3041 {
3042 inspect::InspectTarget::Symbol {
3043 file: file.to_string(),
3044 export_name: export_name.to_string(),
3045 }
3046 }
3047 _ => {
3048 return emit_error(
3049 "--symbol must be formatted as FILE:EXPORT",
3050 2,
3051 dispatch.output,
3052 );
3053 }
3054 },
3055 _ => {
3056 return emit_error(
3057 "inspect requires exactly one of --file or --symbol",
3058 2,
3059 dispatch.output,
3060 );
3061 }
3062 };
3063
3064 let churn_config = if churn {
3065 match load_config_for_analysis(
3066 dispatch.root,
3067 &dispatch.cli.config,
3068 ConfigLoadOptions {
3069 output: dispatch.output,
3070 no_cache: dispatch.cli.no_cache,
3071 threads: dispatch.threads,
3072 production_override: None,
3073 quiet: dispatch.quiet,
3074 allow_remote_extends: dispatch.cli.allow_remote_extends,
3075 },
3076 fallow_config::ProductionAnalysis::Health,
3077 ) {
3078 Ok(config) => Some(config),
3079 Err(code) => return code,
3080 }
3081 } else {
3082 None
3083 };
3084
3085 inspect::run_inspect(&inspect::InspectOptions {
3086 root: dispatch.root,
3087 config_path: dispatch.cli.config.as_ref(),
3088 output: dispatch.output,
3089 no_cache: dispatch.cli.no_cache,
3090 no_production: dispatch.cli.no_production,
3091 max_file_size: dispatch.cli.max_file_size,
3092 threads: dispatch.threads,
3093 quiet: dispatch.quiet,
3094 production: dispatch.cli.production,
3095 workspace: dispatch.cli.workspace.as_ref(),
3096 target,
3097 churn_cache_dir: churn_config
3098 .as_ref()
3099 .map(|config| config.cache_dir.as_path()),
3100 symbol_chain,
3101 })
3102}
3103
3104fn dispatch_trace_command(
3105 dispatch: &DispatchContext<'_>,
3106 symbol: String,
3107 callers: bool,
3108 callees: bool,
3109 depth: Option<u32>,
3110) -> ExitCode {
3111 trace_chain::run_trace(&trace_chain::TraceChainOptions {
3112 root: dispatch.root,
3113 config_path: &dispatch.cli.config,
3114 output: dispatch.output,
3115 no_cache: dispatch.cli.no_cache,
3116 threads: dispatch.threads,
3117 quiet: dispatch.quiet,
3118 allow_remote_extends: dispatch.cli.allow_remote_extends,
3119 target: symbol,
3120 callers,
3121 callees,
3122 depth: depth.unwrap_or(fallow_types::trace_chain::DEFAULT_TRACE_DEPTH),
3123 })
3124}
3125
3126fn dispatch_security_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3127 let Command::Security {
3128 subcommand,
3129 runtime_coverage,
3130 min_invocations_hot,
3131 file,
3132 gate,
3133 surface,
3134 } = command
3135 else {
3136 unreachable!("security dispatcher only handles security commands");
3137 };
3138
3139 let gate = gate.map(security::SecurityGateArg::into_mode);
3140 let cli = dispatch.cli;
3141 let (output, _quiet, fail_on_issues) = dispatch.ci_defaults();
3142 let derived_flags = SecurityDerivedFlagState {
3143 output,
3144 ci: cli.ci,
3145 fail_on_issues,
3146 sarif_file: cli.sarif_file.as_deref(),
3147 summary: cli.summary,
3148 explain: cli.explain,
3149 runtime_coverage: runtime_coverage.as_deref(),
3150 min_invocations_hot,
3151 file: file.as_slice(),
3152 gate,
3153 surface,
3154 };
3155 if let Some(code) = try_run_security_survivors(subcommand.as_ref(), &derived_flags) {
3156 return code;
3157 }
3158
3159 let scoped_files = scoped_security_files(&file, subcommand.as_ref());
3160 run_security_blind_spots_or_default(
3161 dispatch,
3162 &SecurityRunInputs {
3163 scoped_files: &scoped_files,
3164 subcommand: &subcommand,
3165 runtime_coverage: runtime_coverage.as_deref(),
3166 min_invocations_hot,
3167 gate,
3168 surface,
3169 },
3170 &derived_flags,
3171 )
3172}
3173
3174struct SecurityRunInputs<'a> {
3177 scoped_files: &'a [PathBuf],
3178 subcommand: &'a Option<SecuritySubcommand>,
3179 runtime_coverage: Option<&'a Path>,
3180 min_invocations_hot: u64,
3181 gate: Option<security::SecurityGateMode>,
3182 surface: bool,
3183}
3184
3185fn run_security_blind_spots_or_default(
3187 dispatch: &DispatchContext<'_>,
3188 inputs: &SecurityRunInputs<'_>,
3189 derived_flags: &SecurityDerivedFlagState<'_>,
3190) -> ExitCode {
3191 let cli = dispatch.cli;
3192 let (output, quiet, fail_on_issues) = dispatch.ci_defaults();
3193 let opts = security::SecurityOptions {
3194 root: dispatch.root,
3195 config_path: &cli.config,
3196 output,
3197 no_cache: cli.no_cache,
3198 threads: dispatch.threads,
3199 quiet,
3200 allow_remote_extends: cli.allow_remote_extends,
3201 fail_on_issues,
3202 sarif_file: cli.sarif_file.as_deref(),
3203 summary: cli.summary,
3204 changed_since: cli.changed_since.as_deref(),
3205 use_shared_diff_index: true,
3206 workspace: cli.workspace.as_deref(),
3207 changed_workspaces: cli.changed_workspaces.as_deref(),
3208 file: inputs.scoped_files,
3209 surface: inputs.surface,
3210 gate: inputs.gate,
3211 runtime_coverage: inputs.runtime_coverage,
3212 min_invocations_hot: inputs.min_invocations_hot,
3213 explain: cli.explain,
3214 };
3215 if matches!(
3216 inputs.subcommand,
3217 Some(SecuritySubcommand::BlindSpots { .. })
3218 ) {
3219 if let Some(code) = validate_security_blind_spots_flags(derived_flags) {
3220 return code;
3221 }
3222 security::run_blind_spots(&opts)
3223 } else {
3224 security::run(&opts)
3225 }
3226}
3227
3228fn try_run_security_survivors(
3231 subcommand: Option<&SecuritySubcommand>,
3232 flags: &SecurityDerivedFlagState<'_>,
3233) -> Option<ExitCode> {
3234 let Some(SecuritySubcommand::Survivors {
3235 candidates,
3236 verdicts,
3237 require_verdict_for_each_candidate,
3238 }) = subcommand
3239 else {
3240 return None;
3241 };
3242 if let Some(code) = validate_security_survivors_flags(flags) {
3243 return Some(code);
3244 }
3245 Some(security::run_survivors(
3246 &security::SecuritySurvivorsOptions {
3247 output: flags.output,
3248 candidates,
3249 verdicts,
3250 require_verdict_for_each_candidate: *require_verdict_for_each_candidate,
3251 },
3252 ))
3253}
3254
3255fn scoped_security_files(
3257 file: &[PathBuf],
3258 subcommand: Option<&SecuritySubcommand>,
3259) -> Vec<PathBuf> {
3260 let mut scoped_files = file.to_vec();
3261 if let Some(SecuritySubcommand::BlindSpots {
3262 file: blind_spot_files,
3263 }) = subcommand
3264 {
3265 scoped_files.extend(blind_spot_files.iter().cloned());
3266 }
3267 scoped_files
3268}
3269
3270struct SecurityDerivedFlagState<'a> {
3271 output: fallow_config::OutputFormat,
3272 ci: bool,
3273 fail_on_issues: bool,
3274 sarif_file: Option<&'a Path>,
3275 summary: bool,
3276 explain: bool,
3277 runtime_coverage: Option<&'a Path>,
3278 min_invocations_hot: u64,
3279 file: &'a [PathBuf],
3280 gate: Option<security::SecurityGateMode>,
3281 surface: bool,
3282}
3283
3284fn validate_security_survivors_flags(flags: &SecurityDerivedFlagState<'_>) -> Option<ExitCode> {
3285 let flag = if flags.ci {
3286 Some("--ci")
3287 } else if flags.fail_on_issues {
3288 Some("--fail-on-issues")
3289 } else if flags.sarif_file.is_some() {
3290 Some("--sarif-file")
3291 } else if flags.summary {
3292 Some("--summary")
3293 } else if flags.explain {
3294 Some("--explain")
3295 } else if flags.runtime_coverage.is_some() {
3296 Some("--runtime-coverage")
3297 } else if flags.min_invocations_hot != DEFAULT_MIN_INVOCATIONS_HOT {
3298 Some("--min-invocations-hot")
3299 } else if !flags.file.is_empty() {
3300 Some("--file")
3301 } else if flags.gate.is_some() {
3302 Some("--gate")
3303 } else if flags.surface {
3304 Some("--surface")
3305 } else {
3306 None
3307 }?;
3308 Some(emit_error(
3309 &format!("{flag} is not valid with `fallow security survivors`."),
3310 2,
3311 flags.output,
3312 ))
3313}
3314
3315fn validate_security_blind_spots_flags(flags: &SecurityDerivedFlagState<'_>) -> Option<ExitCode> {
3316 let flag = if flags.ci {
3317 Some("--ci")
3318 } else if flags.fail_on_issues {
3319 Some("--fail-on-issues")
3320 } else if flags.sarif_file.is_some() {
3321 Some("--sarif-file")
3322 } else if flags.summary {
3323 Some("--summary")
3324 } else if flags.explain {
3325 Some("--explain")
3326 } else if flags.runtime_coverage.is_some() {
3327 Some("--runtime-coverage")
3328 } else if flags.min_invocations_hot != DEFAULT_MIN_INVOCATIONS_HOT {
3329 Some("--min-invocations-hot")
3330 } else if flags.gate.is_some() {
3331 Some("--gate")
3332 } else if flags.surface {
3333 Some("--surface")
3334 } else {
3335 None
3336 }?;
3337 Some(emit_error(
3338 &format!("{flag} is not valid with `fallow security blind-spots`."),
3339 2,
3340 flags.output,
3341 ))
3342}
3343
3344fn dispatch_dupes_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3345 let Command::Dupes {
3346 mode,
3347 min_tokens,
3348 min_lines,
3349 min_occurrences,
3350 threshold,
3351 skip_local,
3352 cross_language,
3353 ignore_imports,
3354 no_ignore_imports,
3355 top,
3356 trace,
3357 } = command
3358 else {
3359 unreachable!("dupes dispatcher only handles dupes commands");
3360 };
3361
3362 dispatch_dupes(
3363 dispatch,
3364 &DupesDispatchArgs {
3365 mode,
3366 min_tokens,
3367 min_lines,
3368 min_occurrences,
3369 threshold,
3370 skip_local,
3371 cross_language,
3372 ignore_imports,
3373 no_ignore_imports,
3374 top,
3375 trace,
3376 },
3377 )
3378}
3379
3380fn dispatch_init_command(command: Command, root: &Path, quiet: bool) -> ExitCode {
3381 let Command::Init {
3382 toml,
3383 agents,
3384 hooks,
3385 branch,
3386 decline,
3387 } = command
3388 else {
3389 unreachable!("init dispatcher only handles init commands");
3390 };
3391
3392 init::run_init(&init::InitOptions {
3393 root,
3394 use_toml: toml,
3395 agents,
3396 hooks,
3397 branch: branch.as_deref(),
3398 decline,
3399 quiet,
3400 })
3401}
3402
3403fn dispatch_fix_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3404 let Command::Fix {
3405 dry_run,
3406 yes,
3407 no_create_config,
3408 } = command
3409 else {
3410 unreachable!("fix dispatcher only handles fix commands");
3411 };
3412
3413 dispatch_fix(
3414 dispatch,
3415 FixDispatchArgs {
3416 dry_run: *dry_run,
3417 yes: *yes,
3418 no_create_config: *no_create_config,
3419 },
3420 )
3421}
3422
3423fn dispatch_list_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3424 match command {
3425 Command::Workspaces => dispatch_list(dispatch, ListDispatchArgs::workspaces()),
3426 Command::List {
3427 entry_points,
3428 files,
3429 plugins,
3430 boundaries,
3431 workspaces,
3432 } => dispatch_list(
3433 dispatch,
3434 ListDispatchArgs {
3435 entry_points: *entry_points,
3436 files: *files,
3437 plugins: *plugins,
3438 boundaries: *boundaries,
3439 workspaces: *workspaces,
3440 },
3441 ),
3442 _ => unreachable!("list dispatcher only handles list commands"),
3443 }
3444}
3445
3446fn dispatch_migrate_command(command: Command, root: &Path) -> ExitCode {
3447 let Command::Migrate {
3448 toml,
3449 jsonc,
3450 dry_run,
3451 from,
3452 } = command
3453 else {
3454 unreachable!("migrate dispatcher only handles migrate commands");
3455 };
3456
3457 migrate::run_migrate(root, toml, jsonc, dry_run, from.as_deref())
3458}
3459
3460fn dispatch_license_command(
3461 subcommand: LicenseCli,
3462 output: fallow_config::OutputFormat,
3463) -> ExitCode {
3464 license::run(&map_license_subcommand(subcommand), output)
3465}
3466
3467fn dispatch_ci_template_command(subcommand: CiTemplateCli) -> ExitCode {
3468 match subcommand {
3469 CiTemplateCli::Gitlab { vendor, force } => {
3470 ci_template::run_gitlab_template(&ci_template::GitlabTemplateOptions {
3471 vendor_dir: vendor,
3472 force,
3473 })
3474 }
3475 }
3476}
3477
3478fn dispatch_coverage_command(dispatch: &DispatchContext<'_>, subcommand: &CoverageCli) -> ExitCode {
3479 let cli = dispatch.cli;
3480 coverage::run(
3481 map_coverage_subcommand(subcommand, cli.explain),
3482 &coverage::RunContext {
3483 root: dispatch.root,
3484 config_path: &cli.config,
3485 output: dispatch.output,
3486 quiet: dispatch.quiet,
3487 no_cache: cli.no_cache,
3488 threads: dispatch.threads,
3489 explain: cli.explain,
3490 allow_remote_extends: cli.allow_remote_extends,
3491 },
3492 )
3493}
3494
3495fn dispatch_health_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3496 let Command::Health {
3497 max_cyclomatic,
3498 max_cognitive,
3499 max_crap,
3500 top,
3501 sort,
3502 complexity,
3503 complexity_breakdown,
3504 file_scores,
3505 coverage_gaps,
3506 hotspots,
3507 ownership,
3508 ownership_emails,
3509 targets,
3510 css,
3511 effort,
3512 score,
3513 min_score,
3514 min_severity,
3515 report_only,
3516 since,
3517 min_commits,
3518 save_snapshot,
3519 trend,
3520 coverage,
3521 coverage_root,
3522 runtime_coverage,
3523 min_invocations_hot,
3524 min_observation_volume,
3525 low_traffic_threshold,
3526 } = command
3527 else {
3528 unreachable!("health dispatcher only handles health commands");
3529 };
3530
3531 let ownership = ownership || ownership_emails.is_some();
3532 let hotspots = hotspots || ownership;
3533 let args = HealthDispatchArgs {
3534 max_cyclomatic,
3535 max_cognitive,
3536 max_crap,
3537 top,
3538 sort,
3539 complexity,
3540 complexity_breakdown,
3541 file_scores,
3542 coverage_gaps,
3543 hotspots,
3544 ownership,
3545 ownership_emails: ownership_emails.map(EmailModeArg::to_config),
3546 targets,
3547 css,
3548 effort,
3549 score,
3550 min_score,
3551 min_severity: min_severity.map(HealthSeverityCli::to_health_severity),
3552 report_only,
3553 since: since.as_deref(),
3554 min_commits,
3555 save_snapshot: save_snapshot.as_ref(),
3556 trend,
3557 coverage: coverage.as_deref(),
3558 coverage_root: coverage_root.as_deref(),
3559 runtime_coverage: runtime_coverage.as_deref(),
3560 min_invocations_hot,
3561 min_observation_volume,
3562 low_traffic_threshold,
3563 };
3564 dispatch_health(dispatch, &args)
3565}
3566
3567fn dispatch_setup_hooks_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3568 let Command::SetupHooks {
3569 agent,
3570 dry_run,
3571 force,
3572 user,
3573 gitignore_claude,
3574 uninstall,
3575 } = command
3576 else {
3577 unreachable!("setup-hooks dispatcher only handles setup-hooks commands");
3578 };
3579
3580 setup_hooks::run_setup_hooks(&setup_hooks::SetupHooksOptions {
3581 root: dispatch.root,
3582 agent: *agent,
3583 dry_run: *dry_run,
3584 force: *force,
3585 user: *user,
3586 gitignore_claude: *gitignore_claude,
3587 uninstall: *uninstall,
3588 })
3589}
3590
3591fn dispatch_audit_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3592 let Command::Audit {
3593 production_dead_code,
3594 production_health,
3595 production_dupes,
3596 dead_code_baseline,
3597 health_baseline,
3598 dupes_baseline,
3599 max_crap,
3600 coverage,
3601 coverage_root,
3602 no_css,
3603 css_deep,
3604 no_css_deep,
3605 gate,
3606 runtime_coverage,
3607 min_invocations_hot,
3608 gate_marker,
3609 brief,
3610 max_decisions,
3611 walkthrough_guide,
3612 walkthrough_file,
3613 walkthrough,
3614 mark_viewed,
3615 show_cleared,
3616 show_deprioritized,
3617 } = command
3618 else {
3619 unreachable!("audit dispatcher only handles audit commands");
3620 };
3621
3622 let brief = brief || walkthrough_guide || walkthrough || walkthrough_file.is_some();
3625
3626 dispatch_audit(
3627 dispatch,
3628 &AuditDispatchArgs {
3629 production_dead_code,
3630 production_health,
3631 production_dupes,
3632 dead_code_baseline,
3633 health_baseline,
3634 dupes_baseline,
3635 max_crap,
3636 coverage,
3637 coverage_root,
3638 no_css,
3639 css_deep,
3640 no_css_deep,
3641 gate,
3642 runtime_coverage,
3643 min_invocations_hot,
3644 gate_marker,
3645 brief,
3646 max_decisions,
3647 walkthrough_guide,
3648 walkthrough_file,
3649 walkthrough,
3650 mark_viewed,
3651 show_cleared,
3652 show_deprioritized,
3653 },
3654 )
3655}
3656
3657fn dispatch_flags_command(dispatch: &DispatchContext<'_>, top: Option<usize>) -> ExitCode {
3658 let cli = dispatch.cli;
3659 let root = dispatch.root;
3660 let output = dispatch.output;
3661 let quiet = dispatch.quiet;
3662 let threads = dispatch.threads;
3663 let production = match resolve_production_modes(cli, root, output, false, false, false) {
3664 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::DeadCode),
3665 Err(code) => return code,
3666 };
3667 flags::run_flags(&flags::FlagsOptions {
3668 root,
3669 config_path: &cli.config,
3670 output,
3671 no_cache: cli.no_cache,
3672 threads,
3673 quiet,
3674 allow_remote_extends: cli.allow_remote_extends,
3675 production,
3676 workspace: cli.workspace.as_deref(),
3677 changed_workspaces: cli.changed_workspaces.as_deref(),
3678 changed_since: cli.changed_since.as_deref(),
3679 explain: cli.explain,
3680 top,
3681 })
3682}
3683
3684fn dispatch_suppressions_command(
3685 dispatch: &DispatchContext<'_>,
3686 file: &[std::path::PathBuf],
3687) -> ExitCode {
3688 let cli = dispatch.cli;
3689 let root = dispatch.root;
3690 let output = dispatch.output;
3691 let production = match resolve_production_modes(cli, root, output, false, false, false) {
3692 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::DeadCode),
3693 Err(code) => return code,
3694 };
3695 suppressions::run_suppressions(&suppressions::SuppressionsOptions {
3696 root,
3697 config_path: &cli.config,
3698 output,
3699 no_cache: cli.no_cache,
3700 threads: dispatch.threads,
3701 quiet: dispatch.quiet,
3702 allow_remote_extends: cli.allow_remote_extends,
3703 production,
3704 workspace: cli.workspace.as_deref(),
3705 changed_workspaces: cli.changed_workspaces.as_deref(),
3706 changed_since: cli.changed_since.as_deref(),
3707 file,
3708 })
3709}
3710
3711fn dispatch_guard_command(dispatch: &DispatchContext<'_>, files: &[String]) -> ExitCode {
3712 guard::run_guard(&guard::GuardOptions {
3713 root: dispatch.root,
3714 config_path: &dispatch.cli.config,
3715 output: dispatch.output,
3716 quiet: dispatch.quiet,
3717 allow_remote_extends: dispatch.cli.allow_remote_extends,
3718 files,
3719 })
3720}
3721
3722fn dispatch_rule_pack_command(dispatch: &DispatchContext<'_>, subcommand: RulePackCli) -> ExitCode {
3723 let ctx = rule_pack::RulePackContext {
3724 root: dispatch.root,
3725 config_path: &dispatch.cli.config,
3726 output: dispatch.output,
3727 quiet: dispatch.quiet,
3728 no_cache: dispatch.cli.no_cache,
3729 threads: Some(dispatch.threads),
3730 allow_remote_extends: dispatch.cli.allow_remote_extends,
3731 };
3732 rule_pack::run(&map_rule_pack_subcommand(subcommand), &ctx)
3733}
3734
3735fn map_rule_pack_subcommand(subcommand: RulePackCli) -> rule_pack::RulePackSubcommand {
3736 match subcommand {
3737 RulePackCli::Init {
3738 name,
3739 template,
3740 dir,
3741 no_config,
3742 } => rule_pack::RulePackSubcommand::Init(rule_pack::InitArgs {
3743 name,
3744 template,
3745 dir,
3746 no_config,
3747 }),
3748 RulePackCli::List => rule_pack::RulePackSubcommand::List,
3749 RulePackCli::Test { pack } => {
3750 rule_pack::RulePackSubcommand::Test(rule_pack::TestArgs { pack })
3751 }
3752 RulePackCli::Schema => rule_pack::RulePackSubcommand::Schema,
3753 }
3754}
3755
3756fn map_license_subcommand(sub: LicenseCli) -> license::LicenseSubcommand {
3757 match sub {
3758 LicenseCli::Activate {
3759 jwt,
3760 from_file,
3761 stdin,
3762 trial,
3763 email,
3764 } => license::LicenseSubcommand::Activate(license::ActivateArgs {
3765 raw_jwt: jwt,
3766 from_file,
3767 from_stdin: stdin,
3768 trial,
3769 email,
3770 }),
3771 LicenseCli::Status => license::LicenseSubcommand::Status,
3772 LicenseCli::Refresh => license::LicenseSubcommand::Refresh,
3773 LicenseCli::Deactivate => license::LicenseSubcommand::Deactivate,
3774 }
3775}
3776
3777fn map_telemetry_subcommand(sub: TelemetryCli) -> telemetry::TelemetryCommand {
3778 match sub {
3779 TelemetryCli::Status => telemetry::TelemetryCommand::Status,
3780 TelemetryCli::Enable => telemetry::TelemetryCommand::Enable,
3781 TelemetryCli::Disable => telemetry::TelemetryCommand::Disable,
3782 TelemetryCli::Inspect { example } => telemetry::TelemetryCommand::Inspect { example },
3783 }
3784}
3785
3786fn map_ci_subcommand(sub: CiCli) -> ci::CiCommand {
3787 match sub {
3788 command @ CiCli::PlanPrComment { .. } => map_ci_plan_pr_comment(command),
3789 command @ CiCli::PostPrComment { .. } => map_ci_post_pr_comment(command),
3790 command @ CiCli::PostReview { .. } => map_ci_post_review(command),
3791 command @ CiCli::PostCheckRun { .. } => map_ci_post_check_run(command),
3792 command @ CiCli::ReconcileReview { .. } => map_ci_reconcile_review(command),
3793 }
3794}
3795
3796fn map_ci_plan_pr_comment(command: CiCli) -> ci::CiCommand {
3797 let CiCli::PlanPrComment {
3798 body,
3799 marker_id,
3800 clean,
3801 existing_comment_id,
3802 existing_body,
3803 } = command
3804 else {
3805 unreachable!("ci plan-pr-comment mapper called with different variant");
3806 };
3807
3808 ci::CiCommand::PlanPrComment {
3809 body,
3810 marker_id,
3811 clean,
3812 existing_comment_id,
3813 existing_body,
3814 }
3815}
3816
3817fn map_ci_post_pr_comment(command: CiCli) -> ci::CiCommand {
3818 let CiCli::PostPrComment {
3819 provider,
3820 pr,
3821 mr,
3822 body,
3823 envelope,
3824 marker_id,
3825 clean,
3826 repo,
3827 project_id,
3828 api_url,
3829 dry_run,
3830 } = command
3831 else {
3832 unreachable!("ci post-pr-comment mapper called with different variant");
3833 };
3834
3835 ci::CiCommand::PostPrComment {
3836 provider: map_ci_provider(provider),
3837 target: pr.or(mr),
3838 body,
3839 envelope,
3840 marker_id,
3841 clean,
3842 repo,
3843 project_id,
3844 api_url,
3845 dry_run,
3846 }
3847}
3848
3849fn map_ci_post_review(command: CiCli) -> ci::CiCommand {
3850 let CiCli::PostReview {
3851 provider,
3852 pr,
3853 mr,
3854 envelope,
3855 repo,
3856 project_id,
3857 api_url,
3858 dry_run,
3859 } = command
3860 else {
3861 unreachable!("ci post-review mapper called with different variant");
3862 };
3863
3864 ci::CiCommand::PostReview {
3865 provider: map_ci_provider(provider),
3866 target: pr.or(mr),
3867 envelope,
3868 repo,
3869 project_id,
3870 api_url,
3871 dry_run,
3872 }
3873}
3874
3875fn map_ci_post_check_run(command: CiCli) -> ci::CiCommand {
3876 let CiCli::PostCheckRun {
3877 provider,
3878 decision,
3879 repo,
3880 head_sha,
3881 api_url,
3882 split_gates,
3883 dry_run,
3884 } = command
3885 else {
3886 unreachable!("ci post-check-run mapper called with different variant");
3887 };
3888
3889 ci::CiCommand::PostCheckRun {
3890 provider: map_ci_provider(provider),
3891 decision,
3892 repo,
3893 head_sha,
3894 api_url,
3895 split_gates,
3896 dry_run,
3897 }
3898}
3899
3900fn map_ci_reconcile_review(command: CiCli) -> ci::CiCommand {
3901 let CiCli::ReconcileReview {
3902 provider,
3903 pr,
3904 mr,
3905 envelope,
3906 repo,
3907 project_id,
3908 api_url,
3909 dry_run,
3910 } = command
3911 else {
3912 unreachable!("ci reconcile-review mapper called with different variant");
3913 };
3914
3915 ci::CiCommand::ReconcileReview {
3916 provider: map_ci_provider(provider),
3917 target: pr.or(mr),
3918 envelope,
3919 repo,
3920 project_id,
3921 api_url,
3922 dry_run,
3923 }
3924}
3925
3926fn map_ci_provider(provider: CiProviderArg) -> ci::CiProvider {
3927 match provider {
3928 CiProviderArg::Github => ci::CiProvider::Github,
3929 CiProviderArg::Gitlab => ci::CiProvider::Gitlab,
3930 }
3931}
3932
3933fn map_coverage_subcommand(sub: &CoverageCli, explain: bool) -> coverage::CoverageSubcommand {
3934 match sub {
3935 CoverageCli::Setup {
3936 yes,
3937 non_interactive,
3938 json,
3939 } => map_coverage_setup(*yes, *non_interactive, *json, explain),
3940 CoverageCli::Analyze { .. } => map_coverage_analyze(sub),
3941 CoverageCli::UploadInventory { .. } => map_coverage_upload_inventory(sub),
3942 CoverageCli::UploadSourceMaps { .. } => map_coverage_upload_source_maps(sub),
3943 CoverageCli::UploadStaticFindings { .. } => map_coverage_upload_static_findings(sub),
3944 }
3945}
3946
3947fn map_coverage_setup(
3948 yes: bool,
3949 non_interactive: bool,
3950 json: bool,
3951 explain: bool,
3952) -> coverage::CoverageSubcommand {
3953 coverage::CoverageSubcommand::Setup(coverage::SetupArgs {
3954 yes,
3955 non_interactive: non_interactive || json,
3956 json,
3957 explain,
3958 })
3959}
3960
3961fn map_coverage_analyze(sub: &CoverageCli) -> coverage::CoverageSubcommand {
3962 let CoverageCli::Analyze {
3963 runtime_coverage,
3964 cloud,
3965 api_key,
3966 api_endpoint,
3967 repo,
3968 project_id,
3969 coverage_period,
3970 environment,
3971 commit_sha,
3972 production,
3973 min_invocations_hot,
3974 min_observation_volume,
3975 low_traffic_threshold,
3976 top,
3977 blast_radius,
3978 importance,
3979 } = sub
3980 else {
3981 unreachable!("coverage analyze mapper called with non-analyze variant");
3982 };
3983 coverage::CoverageSubcommand::Analyze(coverage::AnalyzeArgs {
3984 runtime_coverage: runtime_coverage.clone(),
3985 cloud: *cloud,
3986 api_key: api_key.clone(),
3987 api_endpoint: api_endpoint.clone(),
3988 repo: repo.clone(),
3989 project_id: project_id.clone(),
3990 coverage_period: *coverage_period,
3991 environment: environment.clone(),
3992 commit_sha: commit_sha.clone(),
3993 production: *production,
3994 min_invocations_hot: *min_invocations_hot,
3995 min_observation_volume: *min_observation_volume,
3996 low_traffic_threshold: *low_traffic_threshold,
3997 top: *top,
3998 blast_radius: *blast_radius,
3999 importance: *importance,
4000 })
4001}
4002
4003fn map_coverage_upload_inventory(sub: &CoverageCli) -> coverage::CoverageSubcommand {
4004 let CoverageCli::UploadInventory {
4005 api_key,
4006 api_endpoint,
4007 project_id,
4008 git_sha,
4009 allow_dirty,
4010 exclude_paths,
4011 path_prefix,
4012 dry_run,
4013 with_callers,
4014 ignore_upload_errors,
4015 } = sub
4016 else {
4017 unreachable!("coverage inventory mapper called with non-inventory variant");
4018 };
4019 coverage::CoverageSubcommand::UploadInventory(coverage::UploadInventoryArgs {
4020 api_key: api_key.clone(),
4021 api_endpoint: api_endpoint.clone(),
4022 project_id: project_id.clone(),
4023 git_sha: git_sha.clone(),
4024 allow_dirty: *allow_dirty,
4025 exclude_paths: exclude_paths.clone(),
4026 path_prefix: path_prefix.clone(),
4027 dry_run: *dry_run,
4028 with_callers: *with_callers,
4029 ignore_upload_errors: *ignore_upload_errors,
4030 })
4031}
4032
4033fn map_coverage_upload_source_maps(sub: &CoverageCli) -> coverage::CoverageSubcommand {
4034 let CoverageCli::UploadSourceMaps {
4035 dir,
4036 include,
4037 exclude,
4038 repo,
4039 git_sha,
4040 endpoint,
4041 strip_path,
4042 dry_run,
4043 concurrency,
4044 fail_fast,
4045 } = sub
4046 else {
4047 unreachable!("coverage source-map mapper called with non-source-map variant");
4048 };
4049 coverage::CoverageSubcommand::UploadSourceMaps(coverage::UploadSourceMapsArgs {
4050 dir: dir.clone(),
4051 include: include.clone(),
4052 exclude: exclude.clone(),
4053 repo: repo.clone(),
4054 git_sha: git_sha.clone(),
4055 endpoint: endpoint.clone(),
4056 strip_path: *strip_path,
4057 dry_run: *dry_run,
4058 concurrency: *concurrency,
4059 fail_fast: *fail_fast,
4060 })
4061}
4062
4063fn map_coverage_upload_static_findings(sub: &CoverageCli) -> coverage::CoverageSubcommand {
4064 let CoverageCli::UploadStaticFindings {
4065 api_key,
4066 api_endpoint,
4067 project_id,
4068 git_sha,
4069 allow_dirty,
4070 dry_run,
4071 ignore_upload_errors,
4072 } = sub
4073 else {
4074 unreachable!("coverage static-findings mapper called with non-static variant");
4075 };
4076 coverage::CoverageSubcommand::UploadStaticFindings(coverage::UploadStaticFindingsArgs {
4077 api_key: api_key.clone(),
4078 api_endpoint: api_endpoint.clone(),
4079 project_id: project_id.clone(),
4080 git_sha: git_sha.clone(),
4081 allow_dirty: *allow_dirty,
4082 dry_run: *dry_run,
4083 ignore_upload_errors: *ignore_upload_errors,
4084 })
4085}
4086
4087struct CheckDispatchArgs {
4088 filters: IssueFilters,
4089 trace_opts: TraceOptions,
4090 include_dupes: bool,
4091 top: Option<usize>,
4092 file: Vec<std::path::PathBuf>,
4093}
4094
4095#[derive(Clone, Copy)]
4096struct ListDispatchArgs {
4097 entry_points: bool,
4098 files: bool,
4099 plugins: bool,
4100 boundaries: bool,
4101 workspaces: bool,
4102}
4103
4104impl ListDispatchArgs {
4105 fn workspaces() -> Self {
4106 Self {
4107 entry_points: false,
4108 files: false,
4109 plugins: false,
4110 boundaries: false,
4111 workspaces: true,
4112 }
4113 }
4114}
4115
4116fn dispatch_watch(dispatch: &DispatchContext<'_>, no_clear: bool) -> ExitCode {
4117 let cli = dispatch.cli;
4118 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
4119 Ok(production) => production,
4120 Err(code) => return code,
4121 };
4122 watch::run_watch(&watch::WatchOptions {
4123 root: dispatch.root,
4124 config_path: &cli.config,
4125 output: dispatch.output,
4126 no_cache: cli.no_cache,
4127 threads: dispatch.threads,
4128 quiet: dispatch.quiet,
4129 allow_remote_extends: cli.allow_remote_extends,
4130 production,
4131 clear_screen: !no_clear,
4132 explain: cli.explain,
4133 include_entry_exports: cli.include_entry_exports,
4134 })
4135}
4136
4137#[derive(Clone, Copy)]
4138struct FixDispatchArgs {
4139 dry_run: bool,
4140 yes: bool,
4141 no_create_config: bool,
4142}
4143
4144fn dispatch_fix(dispatch: &DispatchContext<'_>, args: FixDispatchArgs) -> ExitCode {
4145 let cli = dispatch.cli;
4146 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
4147 Ok(production) => production,
4148 Err(code) => return code,
4149 };
4150 fix::run_fix(&fix::FixOptions {
4151 root: dispatch.root,
4152 config_path: &cli.config,
4153 output: dispatch.output,
4154 no_cache: cli.no_cache,
4155 threads: dispatch.threads,
4156 quiet: dispatch.quiet,
4157 allow_remote_extends: cli.allow_remote_extends,
4158 dry_run: args.dry_run,
4159 yes: args.yes,
4160 production,
4161 no_create_config: args.no_create_config,
4162 })
4163}
4164
4165fn dispatch_list(dispatch: &DispatchContext<'_>, args: ListDispatchArgs) -> ExitCode {
4166 let cli = dispatch.cli;
4167 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
4168 Ok(production) => production,
4169 Err(code) => return code,
4170 };
4171 list::run_list(&ListOptions {
4172 root: dispatch.root,
4173 config_path: &cli.config,
4174 output: dispatch.output,
4175 threads: dispatch.threads,
4176 no_cache: cli.no_cache,
4177 entry_points: args.entry_points,
4178 files: args.files,
4179 plugins: args.plugins,
4180 boundaries: args.boundaries,
4181 workspaces: args.workspaces,
4182 production,
4183 allow_remote_extends: cli.allow_remote_extends,
4184 })
4185}
4186
4187fn dispatch_check(dispatch: &DispatchContext<'_>, args: &CheckDispatchArgs) -> ExitCode {
4188 let cli = dispatch.cli;
4189 let (output, quiet, fail_on_issues) = dispatch.ci_defaults();
4190 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
4191 Ok(production) => production,
4192 Err(code) => return code,
4193 };
4194 check::run_check(&CheckOptions {
4195 root: dispatch.root,
4196 config_path: &cli.config,
4197 output,
4198 no_cache: cli.no_cache,
4199 threads: dispatch.threads,
4200 quiet,
4201 allow_remote_extends: cli.allow_remote_extends,
4202 fail_on_issues,
4203 filters: &args.filters,
4204 changed_since: cli.changed_since.as_deref(),
4205 diff_index: None,
4206 use_shared_diff_index: true,
4207 baseline: cli.baseline.as_deref(),
4208 save_baseline: cli.save_baseline.as_deref(),
4209 sarif_file: cli.sarif_file.as_deref(),
4210 production,
4211 production_override: Some(production),
4212 workspace: cli.workspace.as_deref(),
4213 changed_workspaces: cli.changed_workspaces.as_deref(),
4214 group_by: cli.group_by,
4215 include_dupes: args.include_dupes,
4216 trace_opts: &args.trace_opts,
4217 explain: cli.explain,
4218 top: args.top,
4219 file: &args.file,
4220 include_entry_exports: cli.include_entry_exports,
4221 summary: cli.summary,
4222 regression_opts: dispatch.regression_opts(
4223 cli.changed_since.is_some()
4224 || cli.workspace.is_some()
4225 || cli.changed_workspaces.is_some()
4226 || !args.file.is_empty(),
4227 ),
4228 retain_modules_for_health: false,
4229 defer_performance: false,
4230 })
4231}
4232
4233fn resolve_ignore_imports(ignore_imports: bool, no_ignore_imports: bool) -> Option<bool> {
4239 if no_ignore_imports {
4240 Some(false)
4241 } else if ignore_imports {
4242 Some(true)
4243 } else {
4244 None
4245 }
4246}
4247
4248struct DupesDispatchArgs {
4249 mode: Option<DupesMode>,
4250 min_tokens: Option<usize>,
4251 min_lines: Option<usize>,
4252 min_occurrences: Option<usize>,
4253 threshold: Option<f64>,
4254 skip_local: bool,
4255 cross_language: bool,
4256 ignore_imports: bool,
4257 no_ignore_imports: bool,
4258 top: Option<usize>,
4259 trace: Option<String>,
4260}
4261
4262fn dispatch_dupes(dispatch: &DispatchContext<'_>, args: &DupesDispatchArgs) -> ExitCode {
4263 let cli = dispatch.cli;
4264 let (output, quiet, _fail_on_issues) = dispatch.ci_defaults();
4265 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::Dupes) {
4266 Ok(production) => production,
4267 Err(code) => return code,
4268 };
4269 dupes::run_dupes(&DupesOptions {
4270 root: dispatch.root,
4271 config_path: &cli.config,
4272 output,
4273 no_cache: cli.no_cache,
4274 threads: dispatch.threads,
4275 quiet,
4276 allow_remote_extends: cli.allow_remote_extends,
4277 mode: args.mode,
4278 min_tokens: args.min_tokens,
4279 min_lines: args.min_lines,
4280 min_occurrences: args.min_occurrences,
4281 threshold: args.threshold,
4282 skip_local: args.skip_local,
4283 cross_language: args.cross_language,
4284 ignore_imports: resolve_ignore_imports(args.ignore_imports, args.no_ignore_imports),
4285 top: args.top,
4286 baseline_path: cli.baseline.as_deref(),
4287 save_baseline_path: cli.save_baseline.as_deref(),
4288 production,
4289 production_override: Some(production),
4290 trace: args.trace.as_deref(),
4291 changed_since: cli.changed_since.as_deref(),
4292 diff_index: None,
4293 use_shared_diff_index: true,
4294 changed_files: None,
4295 workspace: cli.workspace.as_deref(),
4296 changed_workspaces: cli.changed_workspaces.as_deref(),
4297 explain: cli.explain,
4298 explain_skipped: cli.explain_skipped,
4299 summary: cli.summary,
4300 group_by: cli.group_by,
4301 performance: cli.performance,
4302 })
4303}
4304
4305struct AuditDispatchArgs {
4306 production_dead_code: bool,
4307 production_health: bool,
4308 production_dupes: bool,
4309 dead_code_baseline: Option<PathBuf>,
4310 health_baseline: Option<PathBuf>,
4311 dupes_baseline: Option<PathBuf>,
4312 max_crap: Option<f64>,
4313 coverage: Option<PathBuf>,
4314 coverage_root: Option<PathBuf>,
4315 no_css: bool,
4316 css_deep: bool,
4317 no_css_deep: bool,
4318 gate: Option<AuditGateArg>,
4319 runtime_coverage: Option<PathBuf>,
4320 min_invocations_hot: u64,
4321 gate_marker: Option<String>,
4322 brief: bool,
4323 max_decisions: usize,
4324 walkthrough_guide: bool,
4326 walkthrough_file: Option<PathBuf>,
4329 walkthrough: bool,
4331 mark_viewed: Vec<PathBuf>,
4333 show_cleared: bool,
4335 show_deprioritized: bool,
4337}
4338
4339struct ResolvedAuditInputs {
4340 audit_cfg: fallow_config::AuditConfig,
4341 cache_dir: PathBuf,
4342 production: ProductionModes,
4343 dead_code_baseline: Option<PathBuf>,
4344 health_baseline: Option<PathBuf>,
4345 dupes_baseline: Option<PathBuf>,
4346 coverage: Option<PathBuf>,
4347}
4348
4349fn dispatch_audit(dispatch: &DispatchContext<'_>, args: &AuditDispatchArgs) -> ExitCode {
4350 let cli = dispatch.cli;
4351 let output = dispatch.output;
4352
4353 if cli.baseline.is_some() || cli.save_baseline.is_some() {
4354 return emit_error(
4355 "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>`)",
4356 2,
4357 output,
4358 );
4359 }
4360
4361 let inputs = match resolve_audit_inputs(dispatch, args) {
4362 Ok(inputs) => inputs,
4363 Err(code) => return code,
4364 };
4365
4366 run_resolved_audit(dispatch, args, &inputs)
4367}
4368
4369fn resolve_audit_inputs(
4370 dispatch: &DispatchContext<'_>,
4371 args: &AuditDispatchArgs,
4372) -> Result<ResolvedAuditInputs, ExitCode> {
4373 let cli = dispatch.cli;
4374 let root = dispatch.root;
4375 let output = dispatch.output;
4376 let config = load_config(
4377 root,
4378 &cli.config,
4379 LoadConfigArgs {
4380 output,
4381 no_cache: cli.no_cache,
4382 threads: dispatch.threads,
4383 production: cli.production,
4384 quiet: dispatch.quiet,
4385 allow_remote_extends: cli.allow_remote_extends,
4386 },
4387 )?;
4388 let cache_dir = config.cache_dir.clone();
4389 let audit_cfg = config.audit;
4390 let production = resolve_production_modes(
4391 cli,
4392 root,
4393 output,
4394 args.production_dead_code,
4395 args.production_health,
4396 args.production_dupes,
4397 )?;
4398 let resolved_dead_code_baseline = resolve_audit_baseline_path(
4399 root,
4400 args.dead_code_baseline.as_deref(),
4401 audit_cfg.dead_code_baseline.as_deref(),
4402 );
4403 let resolved_health_baseline = resolve_audit_baseline_path(
4404 root,
4405 args.health_baseline.as_deref(),
4406 audit_cfg.health_baseline.as_deref(),
4407 );
4408 let resolved_dupes_baseline = resolve_audit_baseline_path(
4409 root,
4410 args.dupes_baseline.as_deref(),
4411 audit_cfg.dupes_baseline.as_deref(),
4412 );
4413 let coverage = args
4414 .coverage
4415 .clone()
4416 .or_else(|| std::env::var("FALLOW_COVERAGE").ok().map(PathBuf::from));
4417
4418 Ok(ResolvedAuditInputs {
4419 audit_cfg,
4420 cache_dir,
4421 production,
4422 dead_code_baseline: resolved_dead_code_baseline,
4423 health_baseline: resolved_health_baseline,
4424 dupes_baseline: resolved_dupes_baseline,
4425 coverage,
4426 })
4427}
4428
4429fn audit_css_enabled(config: &fallow_config::AuditConfig, args: &AuditDispatchArgs) -> bool {
4430 !args.no_css && config.css.unwrap_or(true)
4431}
4432
4433fn audit_css_deep_enabled(config: &fallow_config::AuditConfig, args: &AuditDispatchArgs) -> bool {
4434 audit_css_enabled(config, args)
4435 && !args.no_css_deep
4436 && (args.css_deep || config.css_deep.unwrap_or(true))
4437}
4438
4439fn run_resolved_audit(
4440 dispatch: &DispatchContext<'_>,
4441 args: &AuditDispatchArgs,
4442 inputs: &ResolvedAuditInputs,
4443) -> ExitCode {
4444 let cli = dispatch.cli;
4445 audit::run_audit(
4446 &audit::AuditOptions {
4447 root: dispatch.root,
4448 config_path: &cli.config,
4449 cache_dir: &inputs.cache_dir,
4450 output: dispatch.output,
4451 no_cache: cli.no_cache,
4452 threads: dispatch.threads,
4453 quiet: dispatch.quiet,
4454 allow_remote_extends: cli.allow_remote_extends,
4455 changed_since: cli.changed_since.as_deref(),
4456 production: cli.production,
4457 production_dead_code: Some(inputs.production.dead_code),
4458 production_health: Some(inputs.production.health),
4459 production_dupes: Some(inputs.production.dupes),
4460 workspace: cli.workspace.as_deref(),
4461 changed_workspaces: cli.changed_workspaces.as_deref(),
4462 explain: cli.explain,
4463 explain_skipped: cli.explain_skipped,
4464 performance: cli.performance,
4465 group_by: cli.group_by,
4466 dead_code_baseline: inputs.dead_code_baseline.as_deref(),
4467 health_baseline: inputs.health_baseline.as_deref(),
4468 dupes_baseline: inputs.dupes_baseline.as_deref(),
4469 max_crap: args.max_crap,
4470 coverage: inputs.coverage.as_deref(),
4471 coverage_root: args.coverage_root.as_deref(),
4472 gate: args.gate.map_or(inputs.audit_cfg.gate, Into::into),
4473 include_entry_exports: cli.include_entry_exports,
4474 css: audit_css_enabled(&inputs.audit_cfg, args),
4478 css_deep: audit_css_deep_enabled(&inputs.audit_cfg, args),
4479 runtime_coverage: args.runtime_coverage.as_deref(),
4480 min_invocations_hot: args.min_invocations_hot,
4481 brief: args.brief,
4482 max_decisions: args.max_decisions,
4483 walkthrough_guide: args.walkthrough_guide,
4484 walkthrough: args.walkthrough,
4485 mark_viewed: &args.mark_viewed,
4486 show_cleared: args.show_cleared,
4487 walkthrough_file: args.walkthrough_file.as_deref(),
4488 show_deprioritized: args.show_deprioritized,
4489 },
4490 args.gate_marker.as_deref(),
4491 )
4492}
4493
4494fn dispatch_decision_surface(dispatch: &DispatchContext<'_>, max_decisions: usize) -> ExitCode {
4498 let args = decision_surface_audit_args(max_decisions);
4499 let inputs = match resolve_audit_inputs(dispatch, &args) {
4500 Ok(inputs) => inputs,
4501 Err(code) => return code,
4502 };
4503 audit::run_decision_surface(&decision_surface_audit_options(
4504 dispatch,
4505 &inputs,
4506 max_decisions,
4507 ))
4508}
4509
4510fn decision_surface_audit_args(max_decisions: usize) -> AuditDispatchArgs {
4511 AuditDispatchArgs {
4512 production_dead_code: false,
4513 production_health: false,
4514 production_dupes: false,
4515 dead_code_baseline: None,
4516 health_baseline: None,
4517 dupes_baseline: None,
4518 max_crap: None,
4519 coverage: None,
4520 coverage_root: None,
4521 no_css: true,
4522 css_deep: false,
4523 no_css_deep: false,
4524 gate: None,
4525 runtime_coverage: None,
4526 min_invocations_hot: 0,
4527 gate_marker: None,
4528 brief: true,
4529 max_decisions,
4530 walkthrough_guide: false,
4531 walkthrough_file: None,
4532 walkthrough: false,
4533 mark_viewed: Vec::new(),
4534 show_cleared: false,
4535 show_deprioritized: false,
4536 }
4537}
4538
4539fn decision_surface_audit_options<'a>(
4540 dispatch: &'a DispatchContext<'a>,
4541 inputs: &'a ResolvedAuditInputs,
4542 max_decisions: usize,
4543) -> audit::AuditOptions<'a> {
4544 let cli = dispatch.cli;
4545 audit::AuditOptions {
4546 root: dispatch.root,
4547 config_path: &cli.config,
4548 cache_dir: &inputs.cache_dir,
4549 output: dispatch.output,
4550 no_cache: cli.no_cache,
4551 threads: dispatch.threads,
4552 quiet: dispatch.quiet,
4553 allow_remote_extends: cli.allow_remote_extends,
4554 changed_since: cli.changed_since.as_deref(),
4555 production: cli.production,
4556 production_dead_code: Some(inputs.production.dead_code),
4557 production_health: Some(inputs.production.health),
4558 production_dupes: Some(inputs.production.dupes),
4559 workspace: cli.workspace.as_deref(),
4560 changed_workspaces: cli.changed_workspaces.as_deref(),
4561 explain: cli.explain,
4562 explain_skipped: cli.explain_skipped,
4563 performance: cli.performance,
4564 group_by: cli.group_by,
4565 dead_code_baseline: inputs.dead_code_baseline.as_deref(),
4566 health_baseline: inputs.health_baseline.as_deref(),
4567 dupes_baseline: inputs.dupes_baseline.as_deref(),
4568 max_crap: None,
4569 coverage: None,
4570 coverage_root: None,
4571 gate: inputs.audit_cfg.gate,
4572 include_entry_exports: cli.include_entry_exports,
4573 css: false,
4575 css_deep: false,
4576 runtime_coverage: None,
4577 min_invocations_hot: 0,
4578 brief: true,
4579 max_decisions,
4580 walkthrough_guide: false,
4581 walkthrough: false,
4582 mark_viewed: &[],
4583 show_cleared: false,
4584 walkthrough_file: None,
4585 show_deprioritized: false,
4586 }
4587}
4588
4589struct HealthDispatchArgs<'a> {
4590 max_cyclomatic: Option<u16>,
4591 max_cognitive: Option<u16>,
4592 max_crap: Option<f64>,
4593 top: Option<usize>,
4594 sort: health::SortBy,
4595 complexity: bool,
4596 complexity_breakdown: bool,
4597 file_scores: bool,
4598 coverage_gaps: bool,
4599 hotspots: bool,
4600 ownership: bool,
4601 ownership_emails: Option<fallow_config::EmailMode>,
4602 targets: bool,
4603 css: bool,
4604 effort: Option<EffortFilter>,
4605 score: bool,
4606 min_score: Option<f64>,
4607 min_severity: Option<fallow_output::FindingSeverity>,
4608 report_only: bool,
4609 since: Option<&'a str>,
4610 min_commits: Option<u32>,
4611 save_snapshot: Option<&'a Option<String>>,
4612 trend: bool,
4613 coverage: Option<&'a std::path::Path>,
4614 coverage_root: Option<&'a std::path::Path>,
4615 runtime_coverage: Option<&'a std::path::Path>,
4616 min_invocations_hot: u64,
4617 min_observation_volume: Option<u32>,
4618 low_traffic_threshold: Option<f64>,
4619}
4620
4621struct ResolvedHealthCoverageInputs {
4622 coverage: Option<PathBuf>,
4623 coverage_root: Option<PathBuf>,
4624}
4625
4626fn resolve_health_coverage_inputs(
4627 dispatch: &DispatchContext<'_>,
4628 cli_coverage: Option<&std::path::Path>,
4629 cli_coverage_root: Option<&std::path::Path>,
4630) -> Result<ResolvedHealthCoverageInputs, ExitCode> {
4631 let env_coverage = path_from_env("FALLOW_COVERAGE");
4632 let env_coverage_root = path_from_env("FALLOW_COVERAGE_ROOT");
4633 let needs_config_coverage = cli_coverage.is_none() && env_coverage.is_none();
4634 let needs_config_coverage_root = cli_coverage_root.is_none() && env_coverage_root.is_none();
4635 let config_health = if needs_config_coverage || needs_config_coverage_root {
4636 Some(
4637 load_config(
4638 dispatch.root,
4639 &dispatch.cli.config,
4640 LoadConfigArgs {
4641 output: dispatch.output,
4642 no_cache: dispatch.cli.no_cache,
4643 threads: dispatch.threads,
4644 production: dispatch.cli.production,
4645 quiet: dispatch.quiet,
4646 allow_remote_extends: dispatch.cli.allow_remote_extends,
4647 },
4648 )?
4649 .health,
4650 )
4651 } else {
4652 None
4653 };
4654
4655 Ok(ResolvedHealthCoverageInputs {
4656 coverage: cli_coverage
4657 .map(std::path::Path::to_path_buf)
4658 .or(env_coverage)
4659 .or_else(|| {
4660 config_health
4661 .as_ref()
4662 .and_then(|health| health.coverage.clone())
4663 }),
4664 coverage_root: cli_coverage_root
4665 .map(std::path::Path::to_path_buf)
4666 .or(env_coverage_root)
4667 .or_else(|| {
4668 config_health
4669 .as_ref()
4670 .and_then(|health| health.coverage_root.clone())
4671 }),
4672 })
4673}
4674
4675fn path_from_env(name: &str) -> Option<PathBuf> {
4676 std::env::var_os(name)
4677 .filter(|value| !value.is_empty())
4678 .map(PathBuf::from)
4679}
4680
4681fn validate_health_report_only_gate(
4682 report_only: bool,
4683 min_score: Option<f64>,
4684 min_severity: Option<fallow_output::FindingSeverity>,
4685 output: fallow_config::OutputFormat,
4686) -> Result<(), ExitCode> {
4687 if report_only && (min_score.is_some() || min_severity.is_some()) {
4688 return Err(emit_error(
4689 "--report-only cannot be combined with --min-score or --min-severity. \
4690 --report-only always exits 0; drop it to gate on score/severity, or \
4691 drop the gate flags to stay advisory.",
4692 2,
4693 output,
4694 ));
4695 }
4696
4697 Ok(())
4698}
4699
4700fn resolve_runtime_coverage_options(
4701 runtime_coverage: Option<&std::path::Path>,
4702 min_invocations_hot: u64,
4703 min_observation_volume: Option<u32>,
4704 low_traffic_threshold: Option<f64>,
4705 output: fallow_config::OutputFormat,
4706) -> Result<Option<fallow_engine::health::RuntimeCoverageOptions>, ExitCode> {
4707 let Some(path) = runtime_coverage else {
4708 return Ok(None);
4709 };
4710
4711 health::coverage::prepare_options(
4712 path,
4713 min_invocations_hot,
4714 min_observation_volume,
4715 low_traffic_threshold,
4716 output,
4717 )
4718 .map(Some)
4719}
4720
4721fn dispatch_health(dispatch: &DispatchContext<'_>, args: &HealthDispatchArgs<'_>) -> ExitCode {
4722 let cli = dispatch.cli;
4723 let root = dispatch.root;
4724 let (output, _quiet, _fail_on_issues) = dispatch.ci_defaults();
4725 if let Err(code) = validate_health_report_only_gate(
4726 args.report_only,
4727 args.min_score,
4728 args.min_severity,
4729 output,
4730 ) {
4731 return code;
4732 }
4733 let runtime_coverage = match resolve_runtime_coverage_options(
4734 args.runtime_coverage,
4735 args.min_invocations_hot,
4736 args.min_observation_volume,
4737 args.low_traffic_threshold,
4738 output,
4739 ) {
4740 Ok(options) => options,
4741 Err(code) => return code,
4742 };
4743 let production = match resolve_production_modes(cli, root, output, false, false, false) {
4744 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::Health),
4745 Err(code) => return code,
4746 };
4747 let coverage_inputs =
4748 match resolve_health_coverage_inputs(dispatch, args.coverage, args.coverage_root) {
4749 Ok(inputs) => inputs,
4750 Err(code) => return code,
4751 };
4752 let run = derive_health_dispatch_run(args, output, &coverage_inputs, runtime_coverage);
4753 run_health_dispatch(dispatch, args, ResolvedHealthDispatch { run, production })
4754}
4755
4756fn derive_health_dispatch_run<'a>(
4757 args: &'a HealthDispatchArgs<'a>,
4758 output: fallow_config::OutputFormat,
4759 coverage_inputs: &'a ResolvedHealthCoverageInputs,
4760 runtime_coverage: Option<fallow_engine::health::RuntimeCoverageOptions>,
4761) -> fallow_engine::health::HealthRunOptions<'a> {
4762 fallow_engine::health::derive_health_run_options(fallow_engine::health::HealthRunOptionsInput {
4763 output,
4764 thresholds: health_threshold_overrides(args),
4765 top: args.top,
4766 sort: args.sort.clone().into(),
4767 complexity: args.complexity,
4768 file_scores: args.file_scores,
4769 coverage_gaps: args.coverage_gaps,
4770 hotspots: args.hotspots,
4771 ownership: args.ownership,
4772 ownership_emails: args.ownership_emails,
4773 targets: args.targets,
4774 css: args.css,
4775 effort: args.effort.map(EffortFilter::to_estimate),
4776 score: args.score,
4777 gates: health_gate_options(args),
4778 snapshot_requested: args.save_snapshot.is_some(),
4779 trend: args.trend,
4780 since: args.since,
4781 min_commits: args.min_commits,
4782 coverage_inputs: health_coverage_inputs(coverage_inputs),
4783 runtime_coverage,
4784 })
4785}
4786
4787fn health_threshold_overrides(
4788 args: &HealthDispatchArgs<'_>,
4789) -> fallow_engine::health::HealthThresholdOverrides {
4790 fallow_engine::health::HealthThresholdOverrides {
4791 max_cyclomatic: args.max_cyclomatic,
4792 max_cognitive: args.max_cognitive,
4793 max_crap: args.max_crap,
4794 }
4795}
4796
4797fn health_gate_options(args: &HealthDispatchArgs<'_>) -> fallow_engine::health::HealthGateOptions {
4798 fallow_engine::health::HealthGateOptions {
4799 min_score: args.min_score,
4800 min_severity: args.min_severity,
4801 report_only: args.report_only,
4802 }
4803}
4804
4805fn health_coverage_inputs(
4806 coverage_inputs: &ResolvedHealthCoverageInputs,
4807) -> fallow_engine::health::HealthCoverageInputs<'_> {
4808 fallow_engine::health::HealthCoverageInputs {
4809 coverage: coverage_inputs.coverage.as_deref(),
4810 coverage_root: coverage_inputs.coverage_root.as_deref(),
4811 }
4812}
4813
4814struct ResolvedHealthDispatch<'a> {
4818 run: fallow_engine::health::HealthRunOptions<'a>,
4819 production: bool,
4820}
4821
4822fn run_health_dispatch(
4825 dispatch: &DispatchContext<'_>,
4826 args: &HealthDispatchArgs<'_>,
4827 resolved: ResolvedHealthDispatch<'_>,
4828) -> ExitCode {
4829 let cli = dispatch.cli;
4830 let (output, quiet, _fail_on_issues) = dispatch.ci_defaults();
4831 let run = resolved.run;
4832 let sections = run.sections;
4833 let production = resolved.production;
4834 health::run_health(&HealthOptions {
4835 root: dispatch.root,
4836 config_path: &cli.config,
4837 output,
4838 no_cache: cli.no_cache,
4839 threads: dispatch.threads,
4840 quiet,
4841 thresholds: run.thresholds,
4842 top: run.top,
4843 sort: run.sort,
4844 production,
4845 production_override: Some(production),
4846 allow_remote_extends: cli.allow_remote_extends,
4847 changed_since: cli.changed_since.as_deref(),
4848 diff_index: None,
4849 use_shared_diff_index: true,
4850 workspace: cli.workspace.as_deref(),
4851 changed_workspaces: cli.changed_workspaces.as_deref(),
4852 baseline: cli.baseline.as_deref(),
4853 save_baseline: cli.save_baseline.as_deref(),
4854 complexity: sections.complexity,
4855 file_scores: sections.file_scores,
4856 coverage_gaps: sections.coverage_gaps,
4857 config_activates_coverage_gaps: !sections.any_section,
4858 hotspots: sections.hotspots,
4859 ownership: run.ownership,
4860 ownership_emails: run.ownership_emails,
4861 targets: sections.targets,
4862 css: sections.css,
4863 css_deep: false,
4864 force_full: sections.force_full,
4865 score_only_output: sections.score_only_output,
4866 enforce_coverage_gap_gate: true,
4867 effort: run.effort,
4868 score: sections.score,
4869 gates: run.gates,
4870 since: run.since,
4871 min_commits: run.min_commits,
4872 explain: cli.explain,
4873 summary: cli.summary,
4874 save_snapshot: args
4875 .save_snapshot
4876 .map(|opt| PathBuf::from(opt.as_deref().unwrap_or_default())),
4877 trend: args.trend,
4878 coverage_inputs: run.coverage_inputs,
4879 performance: cli.performance,
4880 runtime_coverage: run.runtime_coverage,
4881 churn_file: cli.churn_file.as_deref(),
4882 complexity_breakdown: args.complexity_breakdown,
4883 group_by: cli.group_by.map(Into::into),
4884 })
4885}
4886
4887#[cfg(test)]
4888mod tests {
4889 use super::*;
4890
4891 #[test]
4895 fn cli_definition_has_no_flag_collisions() {
4896 use clap::CommandFactory;
4897 Cli::command().debug_assert();
4898 }
4899
4900 #[test]
4904 fn after_help_lists_every_task_matrix_command() {
4905 for row in crate::task_matrix::TASK_MATRIX {
4906 assert!(
4907 TOP_LEVEL_AFTER_HELP.contains(row.command),
4908 "root --help cheat sheet is missing task-matrix command '{}'; \
4909 update TOP_LEVEL_AFTER_HELP to match TASK_MATRIX",
4910 row.command
4911 );
4912 }
4913 }
4914
4915 #[test]
4919 fn high_value_commands_route_to_distinct_workflows() {
4920 use clap::Parser;
4921 use fallow_config::OutputFormat;
4922
4923 let distinct = [
4924 (vec!["fallow", "impact"], telemetry::Workflow::Impact),
4925 (vec!["fallow", "security"], telemetry::Workflow::Security),
4926 (vec!["fallow", "fix"], telemetry::Workflow::Fix),
4927 (
4928 vec!["fallow", "explain", "unused-exports"],
4929 telemetry::Workflow::Explain,
4930 ),
4931 (
4932 vec!["fallow", "watch"],
4933 telemetry::Workflow::CodeQualityReview,
4934 ),
4935 (
4936 vec!["fallow", "list"],
4937 telemetry::Workflow::ProjectInventory,
4938 ),
4939 (
4940 vec!["fallow", "workspaces"],
4941 telemetry::Workflow::ProjectInventory,
4942 ),
4943 (
4944 vec!["fallow", "schema"],
4945 telemetry::Workflow::ProjectInventory,
4946 ),
4947 (vec!["fallow", "init"], telemetry::Workflow::Setup),
4948 (
4949 vec!["fallow", "hooks", "install", "--target", "git"],
4950 telemetry::Workflow::Setup,
4951 ),
4952 (vec!["fallow", "config-schema"], telemetry::Workflow::Setup),
4953 (vec!["fallow", "plugin-schema"], telemetry::Workflow::Setup),
4954 (
4955 vec!["fallow", "rule-pack-schema"],
4956 telemetry::Workflow::Setup,
4957 ),
4958 (vec!["fallow", "config"], telemetry::Workflow::Setup),
4959 (
4960 vec!["fallow", "ci-template", "gitlab"],
4961 telemetry::Workflow::Setup,
4962 ),
4963 (vec!["fallow", "migrate"], telemetry::Workflow::Setup),
4964 (
4965 vec!["fallow", "telemetry", "status"],
4966 telemetry::Workflow::Setup,
4967 ),
4968 (vec!["fallow", "setup-hooks"], telemetry::Workflow::Setup),
4969 (
4970 vec!["fallow", "license", "status"],
4971 telemetry::Workflow::License,
4972 ),
4973 ];
4974 for (argv, expected) in distinct {
4975 let cli = Cli::try_parse_from(&argv).expect("argv parses");
4976 assert_eq!(
4977 telemetry_workflow_for_command(cli.command.as_ref(), OutputFormat::Json),
4978 expected,
4979 "{argv:?} should map to {expected:?}"
4980 );
4981 }
4982 }
4983
4984 #[test]
4989 fn version_flag_accepts_lower_v_upper_v_and_long() {
4990 use clap::CommandFactory;
4991 for argv in [["fallow", "-v"], ["fallow", "-V"], ["fallow", "--version"]] {
4992 let err = Cli::command()
4993 .try_get_matches_from(argv)
4994 .expect_err("version flag should short-circuit parsing");
4995 assert_eq!(
4996 err.kind(),
4997 clap::error::ErrorKind::DisplayVersion,
4998 "{argv:?} should trigger the Version action"
4999 );
5000 }
5001 }
5002
5003 #[test]
5008 fn cli_help_text_contains_no_implementation_status_wording() {
5009 use clap::CommandFactory;
5010 let mut root = Cli::command();
5011 let mut violations: Vec<(String, String)> = Vec::new();
5012 visit_help(&mut root, "fallow", &mut violations);
5013 assert!(
5014 violations.is_empty(),
5015 "found implementation-status wording in --help output:\n{}",
5016 violations
5017 .iter()
5018 .map(|(cmd, line)| format!(" {cmd}: {line}"))
5019 .collect::<Vec<_>>()
5020 .join("\n")
5021 );
5022 }
5023
5024 #[test]
5025 fn top_level_help_groups_commands_by_workflow() {
5026 use clap::CommandFactory;
5027 let help = Cli::command().render_long_help().to_string();
5028 let expected_order = [
5029 "Analysis:",
5030 " dead-code",
5031 " dupes",
5032 " health",
5033 " flags",
5034 " security",
5035 " audit",
5036 "Workflow:",
5037 " watch",
5038 " fix",
5039 "Project inspection:",
5040 " list",
5041 " workspaces",
5042 " explain",
5043 " impact",
5044 "Setup and configuration:",
5045 " init",
5046 " recommend",
5047 " migrate",
5048 " config",
5049 " config-schema",
5050 " plugin-schema",
5051 " plugin-check",
5052 " rule-pack-schema",
5053 "Automation and CI:",
5054 " ci",
5055 " ci-template",
5056 " hooks",
5057 " setup-hooks",
5058 "Runtime coverage:",
5059 " coverage",
5060 " license",
5061 "Reference:",
5062 " schema",
5063 " help",
5064 "Options:",
5065 ];
5066 let mut cursor = 0;
5067 for needle in expected_order {
5068 let Some(offset) = help[cursor..].find(needle) else {
5069 panic!("top-level help missing `{needle}` after byte {cursor}:\n{help}");
5070 };
5071 cursor += offset + needle.len();
5072 }
5073 }
5074
5075 #[test]
5076 fn security_help_hides_globals_rejected_by_security_validator() {
5077 let help = render_security_help(SecurityHelpTarget::Parent);
5078
5079 for long in SECURITY_UNSUPPORTED_GLOBAL_LONGS {
5080 assert!(
5081 !help_contains_long_flag(&help, long),
5082 "security help must hide unsupported --{long}:\n{help}"
5083 );
5084 }
5085
5086 for long in [
5087 "root",
5088 "config",
5089 "format",
5090 "quiet",
5091 "no-cache",
5092 "threads",
5093 "changed-since",
5094 "diff-file",
5095 "diff-stdin",
5096 "workspace",
5097 "changed-workspaces",
5098 "ci",
5099 "fail-on-issues",
5100 "sarif-file",
5101 "summary",
5102 "output-file",
5103 "max-file-size",
5104 "explain",
5105 "surface",
5106 ] {
5107 assert!(
5108 help_contains_long_flag(&help, long),
5109 "security help must keep supported --{long}:\n{help}"
5110 );
5111 }
5112 }
5113
5114 #[test]
5115 fn security_help_detection_covers_subcommand_and_help_alias_forms() {
5116 assert_eq!(
5117 security_help_target(["security", "--help"]),
5118 Some(SecurityHelpTarget::Parent)
5119 );
5120 assert_eq!(
5121 security_help_target(["security", "-h"]),
5122 Some(SecurityHelpTarget::Parent)
5123 );
5124 assert_eq!(
5125 security_help_target(["--format", "json", "security", "--help"]),
5126 Some(SecurityHelpTarget::Parent)
5127 );
5128 assert_eq!(
5129 security_help_target(["help", "security"]),
5130 Some(SecurityHelpTarget::Parent)
5131 );
5132 assert_eq!(
5133 security_help_target(["security", "survivors", "--help"]),
5134 Some(SecurityHelpTarget::Survivors)
5135 );
5136 assert_eq!(
5137 security_help_target(["security", "survivors", "-h"]),
5138 Some(SecurityHelpTarget::Survivors)
5139 );
5140 assert_eq!(
5141 security_help_target(["help", "security", "survivors"]),
5142 Some(SecurityHelpTarget::Survivors)
5143 );
5144 assert_eq!(
5145 security_help_target(["security", "blind-spots", "--help"]),
5146 Some(SecurityHelpTarget::BlindSpots)
5147 );
5148 assert_eq!(
5149 security_help_target(["help", "security", "blind-spots"]),
5150 Some(SecurityHelpTarget::BlindSpots)
5151 );
5152 assert_eq!(security_help_target(["health", "--help"]), None);
5153 assert_eq!(security_help_target(["help", "health"]), None);
5154 }
5155
5156 #[test]
5157 fn security_unsupported_global_validator_matches_hidden_help_contract() {
5158 for (argv, expected) in [
5159 (vec!["fallow", "security", "--performance"], "--performance"),
5160 (
5161 vec!["fallow", "security", "--baseline", "base.json"],
5162 "--baseline",
5163 ),
5164 (
5165 vec!["fallow", "security", "--dupes-mode", "weak"],
5166 "--dupes-mode",
5167 ),
5168 ] {
5169 let cli = Cli::try_parse_from(argv).expect("security global parses before validation");
5170 assert_eq!(unsupported_security_global(&cli), Some(expected));
5171 }
5172
5173 let explain = Cli::try_parse_from(["fallow", "security", "--explain"])
5174 .expect("security --explain parses");
5175 assert_eq!(unsupported_security_global(&explain), None);
5176 }
5177
5178 #[test]
5179 fn programmatic_common_options_track_analysis_affecting_cli_globals() {
5180 use clap::CommandFactory;
5181
5182 let cli_flags: std::collections::BTreeSet<String> = Cli::command()
5183 .get_arguments()
5184 .filter(|arg| arg.is_global_set())
5185 .filter_map(|arg| arg.get_long().map(str::to_owned))
5186 .filter(|name| {
5187 matches!(
5188 name.as_str(),
5189 "root"
5190 | "config"
5191 | "allow-remote-extends"
5192 | "no-cache"
5193 | "threads"
5194 | "changed-since"
5195 | "diff-file"
5196 | "production"
5197 | "workspace"
5198 | "changed-workspaces"
5199 | "explain"
5200 )
5201 })
5202 .collect();
5203 let programmatic_flags: std::collections::BTreeSet<String> =
5204 fallow_api::COMMON_ANALYSIS_OPTION_FLAGS
5205 .iter()
5206 .map(|flag| (*flag).to_owned())
5207 .collect();
5208
5209 assert_eq!(programmatic_flags, cli_flags);
5210 }
5211
5212 #[test]
5213 fn dead_code_registry_filter_flags_are_exposed_by_clap() {
5214 use clap::CommandFactory;
5215
5216 let cli = Cli::command();
5217 let dead_code = cli
5218 .get_subcommands()
5219 .find(|command| command.get_name() == "dead-code")
5220 .expect("dead-code subcommand is registered");
5221 let cli_flags: std::collections::BTreeSet<String> = dead_code
5222 .get_arguments()
5223 .filter_map(|arg| arg.get_long().map(|long| format!("--{long}")))
5224 .collect();
5225
5226 for flag in fallow_types::issue_meta::DEAD_CODE_FILTER_FLAGS.iter() {
5227 assert!(
5228 cli_flags.contains(*flag),
5229 "registry filter flag {flag} is missing from dead-code clap args"
5230 );
5231 }
5232 }
5233
5234 fn help_contains_long_flag(help: &str, long: &str) -> bool {
5235 let flag = format!("--{long}");
5236 help.split(|c: char| c.is_whitespace() || c == ',' || c == '[' || c == ']')
5237 .any(|token| token == flag)
5238 }
5239
5240 fn visit_help(cmd: &mut clap::Command, path: &str, violations: &mut Vec<(String, String)>) {
5241 let help = cmd.render_long_help().to_string();
5242 for line in scan_forbidden(&help) {
5243 violations.push((path.to_owned(), line));
5244 }
5245 let names: Vec<String> = cmd
5246 .get_subcommands()
5247 .map(|sub| sub.get_name().to_owned())
5248 .collect();
5249 for name in names {
5250 if name == "help" {
5251 continue;
5252 }
5253 if let Some(sub) = cmd.find_subcommand_mut(&name) {
5254 let sub_path = format!("{path} {name}");
5255 visit_help(sub, &sub_path, violations);
5256 }
5257 }
5258 }
5259
5260 fn scan_forbidden(s: &str) -> Vec<String> {
5261 let lower = s.to_ascii_lowercase();
5262 let mut out = Vec::new();
5263 for word in ["stub", "placeholder"] {
5264 if let Some(idx) = find_whole_word(&lower, word) {
5265 out.push(extract_line(s, idx));
5266 }
5267 }
5268 if let Some(idx) = lower.find("not yet") {
5269 out.push(extract_line(s, idx));
5270 }
5271 out
5272 }
5273
5274 fn find_whole_word(haystack: &str, word: &str) -> Option<usize> {
5275 let bytes = haystack.as_bytes();
5276 let mut start = 0;
5277 while let Some(rel) = haystack[start..].find(word) {
5278 let abs = start + rel;
5279 let before_ok = abs == 0 || !bytes[abs - 1].is_ascii_alphanumeric();
5280 let after_idx = abs + word.len();
5281 let after_ok = after_idx >= bytes.len() || !bytes[after_idx].is_ascii_alphanumeric();
5282 if before_ok && after_ok {
5283 return Some(abs);
5284 }
5285 start = abs + word.len();
5286 }
5287 None
5288 }
5289
5290 fn extract_line(s: &str, byte_idx: usize) -> String {
5291 let line_start = s[..byte_idx].rfind('\n').map_or(0, |i| i + 1);
5292 let line_end = s[byte_idx..].find('\n').map_or(s.len(), |i| byte_idx + i);
5293 s[line_start..line_end].trim().to_owned()
5294 }
5295
5296 #[test]
5297 fn emit_error_returns_given_exit_code() {
5298 let code = emit_error("test error", 2, fallow_config::OutputFormat::Human);
5299 assert_eq!(code, ExitCode::from(2));
5300 }
5301
5302 fn telemetry_run_for_mode(mode: telemetry::AnalysisMode) -> TelemetryRun {
5303 TelemetryRun {
5304 workflow: telemetry::Workflow::Health,
5305 output: fallow_config::OutputFormat::Json,
5306 quiet: true,
5307 start: std::time::Instant::now(),
5308 context: telemetry::WorkflowContext {
5309 run_scope: telemetry::RunScope::FullProject,
5310 config_shape: telemetry::ConfigShape::Default,
5311 output_destination: telemetry::OutputDestination::Stdout,
5312 analysis_mode: mode,
5313 },
5314 }
5315 }
5316
5317 #[test]
5318 fn fallback_failure_reason_skips_success_and_findings() {
5319 let run = telemetry_run_for_mode(telemetry::AnalysisMode::Static);
5320
5321 assert_eq!(fallback_failure_reason_for(&run, ExitCode::SUCCESS), None);
5322 assert_eq!(fallback_failure_reason_for(&run, ExitCode::from(1)), None);
5323 }
5324
5325 #[test]
5326 fn fallback_failure_reason_classifies_network_auth_and_analysis() {
5327 let static_run = telemetry_run_for_mode(telemetry::AnalysisMode::Static);
5328 let cloud_run = telemetry_run_for_mode(telemetry::AnalysisMode::ProductionCoverage);
5329
5330 assert_eq!(
5331 fallback_failure_reason_for(&static_run, ExitCode::from(api::NETWORK_EXIT_CODE)),
5332 Some(telemetry::FailureReason::Network),
5333 );
5334 assert_eq!(
5335 fallback_failure_reason_for(&static_run, ExitCode::from(12)),
5336 Some(telemetry::FailureReason::Auth),
5337 );
5338 assert_eq!(
5339 fallback_failure_reason_for(&cloud_run, ExitCode::from(3)),
5340 Some(telemetry::FailureReason::Auth),
5341 );
5342 assert_eq!(
5343 fallback_failure_reason_for(&static_run, ExitCode::from(2)),
5344 Some(telemetry::FailureReason::Analysis),
5345 );
5346 }
5347
5348 #[test]
5349 fn bare_coverage_flags_parse_without_subcommand() {
5350 let cli = Cli::try_parse_from([
5351 "fallow",
5352 "--coverage",
5353 "coverage/coverage-final.json",
5354 "--coverage-root",
5355 "/ci/workspace",
5356 ])
5357 .expect("bare combined coverage flags should parse");
5358 assert!(cli.command.is_none());
5359 assert_eq!(
5360 cli.coverage.as_deref(),
5361 Some(std::path::Path::new("coverage/coverage-final.json"))
5362 );
5363 assert_eq!(
5364 cli.coverage_root.as_deref(),
5365 Some(std::path::Path::new("/ci/workspace"))
5366 );
5367 }
5368
5369 #[test]
5370 fn bare_coverage_before_subcommand_is_detectable() {
5371 let cli = Cli::try_parse_from([
5372 "fallow",
5373 "--coverage",
5374 "coverage/coverage-final.json",
5375 "dead-code",
5376 ])
5377 .expect("clap should parse pre-subcommand bare coverage for custom rejection");
5378 assert!(cli.command.is_some());
5379 assert!(cli_has_bare_coverage_input(&cli));
5380 let message = bare_coverage_subcommand_error_message();
5381 assert!(message.contains("bare combined-mode flags"));
5382 assert!(message.contains("fallow health --coverage <coverage-final.json>"));
5383 }
5384
5385 #[test]
5386 fn subcommand_coverage_flag_keeps_regular_clap_error() {
5387 let Err(err) = Cli::try_parse_from(["fallow", "dead-code", "--coverage"]) else {
5388 panic!("dead-code --coverage should fail to parse");
5389 };
5390 assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
5391 }
5392
5393 #[test]
5394 fn format_parsing_covers_all_variants() {
5395 assert!(matches!(parse_format_arg("json"), Some(Format::Json)));
5396 assert!(matches!(parse_format_arg("JSON"), Some(Format::Json)));
5397 assert!(matches!(parse_format_arg("human"), Some(Format::Human)));
5398 assert!(matches!(parse_format_arg("sarif"), Some(Format::Sarif)));
5399 assert!(matches!(parse_format_arg("compact"), Some(Format::Compact)));
5400 assert!(matches!(
5401 parse_format_arg("markdown"),
5402 Some(Format::Markdown)
5403 ));
5404 assert!(matches!(parse_format_arg("md"), Some(Format::Markdown)));
5405 assert!(matches!(
5406 parse_format_arg("codeclimate"),
5407 Some(Format::CodeClimate)
5408 ));
5409 assert!(matches!(
5410 parse_format_arg("gitlab-codequality"),
5411 Some(Format::CodeClimate)
5412 ));
5413 assert!(matches!(
5414 parse_format_arg("gitlab-code-quality"),
5415 Some(Format::CodeClimate)
5416 ));
5417 assert!(matches!(
5418 parse_format_arg("pr-comment-github"),
5419 Some(Format::PrCommentGithub)
5420 ));
5421 assert!(matches!(
5422 parse_format_arg("pr-comment-gitlab"),
5423 Some(Format::PrCommentGitlab)
5424 ));
5425 assert!(matches!(
5426 parse_format_arg("review-github"),
5427 Some(Format::ReviewGithub)
5428 ));
5429 assert!(matches!(
5430 parse_format_arg("review-gitlab"),
5431 Some(Format::ReviewGitlab)
5432 ));
5433 assert!(matches!(parse_format_arg("badge"), Some(Format::Badge)));
5434 assert!(parse_format_arg("xml").is_none());
5435 assert!(parse_format_arg("").is_none());
5436 }
5437
5438 #[test]
5439 fn quiet_parsing_logic() {
5440 let parse = |s: &str| -> bool { s == "1" || s.eq_ignore_ascii_case("true") };
5441 assert!(parse("1"));
5442 assert!(parse("true"));
5443 assert!(parse("TRUE"));
5444 assert!(parse("True"));
5445 assert!(!parse("0"));
5446 assert!(!parse("false"));
5447 assert!(!parse("yes"));
5448 }
5449
5450 #[test]
5451 fn tracing_filter_defaults_to_warn_without_env() {
5452 assert_eq!(build_tracing_filter(None).to_string(), "warn");
5453 }
5454
5455 #[test]
5456 fn tracing_filter_respects_explicit_env_directives() {
5457 assert_eq!(build_tracing_filter(Some("info")).to_string(), "info");
5458 }
5459
5460 #[test]
5461 fn tracing_filter_treats_empty_env_as_off() {
5462 assert_eq!(build_tracing_filter(Some("")).to_string(), "off");
5463 assert_eq!(build_tracing_filter(Some(" ")).to_string(), "off");
5464 }
5465}