1#![expect(
2 clippy::print_stdout,
3 clippy::print_stderr,
4 reason = "CLI binary produces intentional terminal output"
5)]
6#![cfg_attr(
7 test,
8 allow(
9 clippy::unwrap_used,
10 clippy::expect_used,
11 reason = "tests use unwrap and expect to keep fixture setup concise"
12 )
13)]
14
15use std::io::IsTerminal as _;
16use std::path::{Path, PathBuf};
17use std::process::ExitCode;
18
19use clap::{Parser, Subcommand};
20
21mod api;
22#[cfg(test)]
23mod architecture_boundaries;
24mod audit;
25mod audit_brief;
26mod audit_decision_surface;
27mod audit_focus;
28mod audit_walkthrough;
29mod base_worktree;
30pub use base_worktree::canonical_root_hash;
34mod walkthrough_state;
35use fallow_engine::baseline;
36mod cache_notice;
37mod check;
38mod ci;
39mod ci_template;
40mod cli_format;
41mod cli_hooks;
42mod cli_impact;
43mod cli_production;
44mod cli_report;
45mod cli_startup;
46pub use fallow_engine::codeowners;
47mod combined;
48mod config;
49mod coverage;
50mod dupes;
51pub mod explain;
52mod fix;
53mod flags;
54mod guard;
55mod health;
56mod impact;
57mod init;
58mod inspect;
59mod json_style;
60mod license;
61mod list;
62mod migrate;
63mod onboarding;
64#[cfg(test)]
65mod output_envelope;
66mod output_runtime;
67mod path_util;
68mod plugin_check;
69mod rayon_pool;
70mod regression;
71pub mod report;
72mod rule_pack;
73mod runtime_support;
74mod schema;
75mod security;
76mod security_help;
77mod setup_hooks;
78mod signal;
79mod suppressions;
80mod task_matrix;
81mod telemetry;
82mod trace_chain;
83mod update_check;
84use fallow_engine::validate;
85use fallow_engine::vital_signs;
86mod cli_telemetry;
87mod viz;
88mod watch;
89
90use check::{CheckOptions, IssueFilters, TraceOptions};
91pub(crate) mod error;
93#[cfg(test)]
94use cli_format::parse_format_arg;
95use cli_format::{Format, FormatConfig};
96use cli_hooks::{HooksCli, run_hooks_command};
97use cli_impact::{ImpactCli, ImpactCrossRepoOpts, ImpactSortCli, dispatch_impact};
98use cli_production::{ProductionModes, resolve_production_modes};
99#[cfg(test)]
100use cli_startup::build_tracing_filter;
101use cli_startup::{
102 bare_coverage_subcommand_error_message, cli_has_bare_coverage_input, parse_cli_args,
103 run_pre_dispatch_checks, setup_tracing, validate_inputs,
104};
105#[cfg(test)]
106use cli_telemetry::TelemetryRun;
107#[cfg(test)]
108use cli_telemetry::{fallback_failure_reason_for, telemetry_workflow_for_command};
109use cli_telemetry::{record_run_epilogue, start_telemetry_run};
110use dupes::{DupesMode, DupesOptions};
111use error::emit_error;
112use health::{HealthOptions, SortBy};
113use list::ListOptions;
114pub(crate) use runtime_support::{AnalysisKind, GroupBy};
115pub(crate) use runtime_support::{
116 ConfigLoadOptions, LoadConfigArgs, build_ownership_resolver, load_config,
117 load_config_for_analysis,
118};
119#[cfg(test)]
120use security_help::{SECURITY_UNSUPPORTED_GLOBAL_LONGS, SecurityHelpTarget};
121use security_help::{render_security_help, security_help_target};
122
123const DEFAULT_MIN_INVOCATIONS_HOT: u64 = 100;
124
125const TOP_LEVEL_HELP_TEMPLATE: &str =
126 "{about-with-newline}\n{usage-heading} {usage}{after-help}\n\nOptions:\n{options}";
127
128const TOP_LEVEL_AFTER_HELP: &str = "\
129Analysis:
130 dead-code Analyze unused code, dependency hygiene, and architecture cycles
131 dupes Find copy-paste and structural code duplication
132 health Analyze complexity, maintainability, hotspots, and coverage gaps
133 flags Detect feature flag usage patterns
134 security Surface local security candidates for agent verification (opt-in)
135 audit Review changed files for dead code, complexity, duplication, and styling
136
137Workflow:
138 watch Re-run analysis as files change
139 fix Auto-fix safe unused-code findings
140
141Project inspection:
142 list List discovered files, entry points, plugins, boundaries, and workspaces
143 inspect Inspect one file or exported symbol as a bundled evidence query
144 workspaces Show monorepo workspace discovery diagnostics
145 explain Explain one issue type without running analysis
146 suppressions List active fallow-ignore suppression markers
147 impact Show what fallow has done for you (opt-in, local-only)
148 viz Generate an interactive HTML map of the codebase
149
150Setup and configuration:
151 init Create a fallow config, optionally with a Git hook
152 audit-cache Maintain reusable audit base-snapshot caches
153 recommend Recommend a project-tailored config for an agent to author
154 migrate Migrate knip, jscpd, or stylelint config to fallow
155 config Show the resolved config and loaded config file
156 config-schema Print the fallow config JSON Schema
157 plugin-schema Print the external plugin JSON Schema
158 plugin-check Dry-run external plugins and report what they seed
159 rule-pack-schema Print the rule pack JSON Schema
160
161Automation and CI:
162 ci Build PR/MR feedback envelopes
163 ci-template Print or vendor CI integration templates
164 report Re-render saved JSON as GitHub or CodeClimate output
165 hooks Install or remove fallow-managed Git and agent hooks
166 setup-hooks Legacy agent-hook installer
167
168Runtime coverage:
169 coverage Set up or analyze runtime coverage data
170 license Manage the paid-feature license
171 telemetry Manage opt-in product telemetry
172
173Reference:
174 schema Dump the CLI interface as machine-readable JSON
175 help Print this message or the help of a command
176
177When no command is given, fallow runs dead-code + dupes + health together.
178Use --only/--skip to select specific analyses.
179
180When the agent is about to...
181 delete an \"unused\" export or file fallow dead-code --trace <file>:<export>
182 prove exact TypeScript symbol consumers fallow dead-code --type-aware --symbol-impact <file>:<export-or-class.method>
183 delete an \"unused\" dependency fallow dead-code --trace-dependency <name>
184 commit or open a PR fallow audit --base <ref>
185 prioritize refactoring fallow health --hotspots --targets
186 ask who owns code fallow health --ownership
187 check untested-but-reachable code fallow health --coverage-gaps
188 consolidate duplication fallow dupes --trace dup:<fingerprint>
189 find feature flags fallow flags
190 check architecture rules before editing fallow guard <files>
191 surface security candidates fallow security
192 inspect a target before editing fallow inspect --file <path>
193 understand a finding fallow explain <issue-type>
194 scope a monorepo --workspace <glob> / --changed-workspaces <ref>";
195
196#[derive(Parser)]
197#[command(
198 name = "fallow",
199 about = "Codebase analyzer for TypeScript/JavaScript: unused code, circular dependencies, code duplication, complexity hotspots, and architecture boundary violations",
200 version,
201 disable_version_flag = true,
202 help_template = TOP_LEVEL_HELP_TEMPLATE,
203 after_help = TOP_LEVEL_AFTER_HELP
204)]
205struct Cli {
206 #[command(subcommand)]
207 command: Option<Command>,
208
209 #[arg(
213 short = 'v',
214 visible_short_alias = 'V',
215 long = "version",
216 action = clap::ArgAction::Version
217 )]
218 version: Option<bool>,
219
220 #[arg(short, long, global = true)]
222 root: Option<PathBuf>,
223
224 #[arg(short, long, global = true)]
226 config: Option<PathBuf>,
227
228 #[arg(long, global = true)]
230 allow_remote_extends: bool,
231
232 #[arg(
234 short,
235 long,
236 visible_alias = "output",
237 global = true,
238 default_value = "human"
239 )]
240 format: Format,
241
242 #[arg(long, global = true)]
244 pretty: bool,
245
246 #[arg(short, long, global = true)]
248 quiet: bool,
249
250 #[arg(long, global = true)]
252 no_cache: bool,
253
254 #[arg(long, global = true)]
256 threads: Option<usize>,
257
258 #[arg(long, visible_alias = "base", global = true)]
260 changed_since: Option<String>,
261
262 #[arg(long = "diff-file", value_name = "PATH", global = true)]
267 diff_file: Option<PathBuf>,
268
269 #[arg(long = "diff-stdin", global = true)]
272 diff_stdin: bool,
273
274 #[arg(long = "churn-file", value_name = "PATH", global = true)]
281 churn_file: Option<PathBuf>,
282
283 #[arg(long = "max-file-size", value_name = "MB", global = true)]
290 max_file_size: Option<u32>,
291
292 #[arg(long, global = true)]
294 baseline: Option<PathBuf>,
295
296 #[arg(long = "baseline-mode", value_enum, global = true)]
311 baseline_mode: Option<BaselineModeArg>,
312
313 #[arg(long, global = true, value_name = "RUN_ID", hide = true)]
319 parent_run: Option<String>,
320
321 #[arg(long, global = true)]
323 save_baseline: Option<PathBuf>,
324
325 #[arg(long, global = true)]
328 production: bool,
329
330 #[arg(long = "no-production", global = true, conflicts_with = "production")]
334 no_production: bool,
335
336 #[arg(long = "production-dead-code")]
338 production_dead_code: bool,
339
340 #[arg(long = "production-health")]
342 production_health: bool,
343
344 #[arg(long = "production-dupes")]
346 production_dupes: bool,
347
348 #[arg(short, long, global = true, value_delimiter = ',')]
352 workspace: Option<Vec<String>>,
353
354 #[arg(long, global = true, value_name = "REF")]
357 changed_workspaces: Option<String>,
358
359 #[arg(long, global = true)]
361 group_by: Option<GroupBy>,
362
363 #[arg(long, global = true)]
365 performance: bool,
366
367 #[arg(long, global = true)]
369 explain: bool,
370
371 #[arg(long, global = true)]
373 explain_skipped: bool,
374
375 #[arg(long, global = true)]
377 summary: bool,
378
379 #[arg(long, global = true)]
381 ci: bool,
382
383 #[arg(long, global = true)]
385 fail_on_issues: bool,
386
387 #[arg(long, global = true, value_name = "PATH")]
389 sarif_file: Option<PathBuf>,
390
391 #[arg(short = 'o', long, global = true, value_name = "PATH")]
395 output_file: Option<PathBuf>,
396
397 #[arg(
406 long = "report-path-prefix",
407 visible_alias = "annotations-path-prefix",
408 global = true,
409 value_name = "PREFIX"
410 )]
411 report_path_prefix: Option<String>,
412
413 #[arg(long, global = true)]
415 fail_on_regression: bool,
416
417 #[arg(long, global = true, value_name = "TOLERANCE", default_value = "0")]
419 tolerance: String,
420
421 #[arg(long, global = true, value_name = "PATH")]
423 regression_baseline: Option<PathBuf>,
424
425 #[expect(
429 clippy::option_option,
430 reason = "clap pattern: None=not passed, Some(None)=flag only (write to config), Some(Some(path))=write to file"
431 )]
432 #[arg(long, global = true, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
433 save_regression_baseline: Option<Option<String>>,
434
435 #[arg(long, value_delimiter = ',')]
437 only: Vec<AnalysisKind>,
438
439 #[arg(long, value_delimiter = ',')]
441 skip: Vec<AnalysisKind>,
442
443 #[arg(long = "dupes-mode", global = true)]
445 dupes_mode: Option<DupesMode>,
446
447 #[arg(long = "dupes-threshold", global = true)]
449 dupes_threshold: Option<f64>,
450
451 #[arg(long = "dupes-min-tokens", global = true)]
453 dupes_min_tokens: Option<usize>,
454
455 #[arg(long = "dupes-min-lines", global = true)]
457 dupes_min_lines: Option<usize>,
458
459 #[arg(long = "dupes-min-occurrences", global = true, value_parser = parse_min_occurrences)]
461 dupes_min_occurrences: Option<usize>,
462
463 #[arg(long = "dupes-skip-local", global = true)]
465 dupes_skip_local: bool,
466
467 #[arg(long = "dupes-cross-language", global = true)]
469 dupes_cross_language: bool,
470
471 #[arg(long = "dupes-ignore-imports", global = true)]
474 dupes_ignore_imports: bool,
475
476 #[arg(
479 long = "dupes-no-ignore-imports",
480 global = true,
481 conflicts_with = "dupes_ignore_imports"
482 )]
483 dupes_no_ignore_imports: bool,
484
485 #[arg(long)]
487 score: bool,
488
489 #[arg(long)]
491 trend: bool,
492
493 #[expect(
496 clippy::option_option,
497 reason = "clap pattern: None=not passed, Some(None)=default path, Some(Some(path))=custom path"
498 )]
499 #[arg(long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
500 save_snapshot: Option<Option<String>>,
501
502 #[arg(long, value_name = "PATH")]
505 coverage: Option<PathBuf>,
506
507 #[arg(long = "coverage-root", value_name = "PATH")]
510 coverage_root: Option<PathBuf>,
511
512 #[arg(long, global = true)]
514 include_entry_exports: bool,
515
516 #[arg(long, global = true)]
519 type_aware: bool,
520
521 #[arg(long, global = true, conflicts_with = "type_aware")]
524 no_type_aware: bool,
525
526 #[arg(long, global = true, value_name = "PATH", action = clap::ArgAction::Append)]
528 type_aware_project: Vec<PathBuf>,
529
530 #[arg(long, global = true, value_enum)]
532 type_aware_require: Option<TypeAwareRequireArg>,
533}
534
535impl Cli {
536 const fn type_aware_override(&self) -> Option<bool> {
540 if self.no_type_aware {
541 Some(false)
542 } else if self.type_aware {
543 Some(true)
544 } else {
545 None
546 }
547 }
548}
549
550#[derive(Clone, Copy, Subcommand)]
551enum TypeAwareCli {
552 Status,
554}
555
556#[derive(Subcommand)]
557enum Command {
558 #[command(name = "dead-code", alias = "check")]
560 Check {
561 #[arg(long)]
563 unused_files: bool,
564
565 #[arg(long)]
567 unused_exports: bool,
568
569 #[arg(long)]
571 unused_deps: bool,
572
573 #[arg(long)]
575 unused_types: bool,
576
577 #[arg(long)]
579 private_type_leaks: bool,
580
581 #[arg(long)]
583 unused_enum_members: bool,
584
585 #[arg(long)]
587 unused_class_members: bool,
588
589 #[arg(long)]
591 unused_store_members: bool,
592
593 #[arg(long)]
595 unprovided_injects: bool,
596
597 #[arg(long)]
599 unrendered_components: bool,
600
601 #[arg(long)]
603 unused_component_props: bool,
604
605 #[arg(long)]
607 unused_component_emits: bool,
608
609 #[arg(long)]
611 unused_component_inputs: bool,
612
613 #[arg(long)]
615 unused_component_outputs: bool,
616
617 #[arg(long)]
619 unused_svelte_events: bool,
620
621 #[arg(long)]
623 unused_server_actions: bool,
624
625 #[arg(long)]
627 unused_load_data_keys: bool,
628
629 #[arg(long)]
631 unresolved_imports: bool,
632
633 #[arg(long)]
635 unlisted_deps: bool,
636
637 #[arg(long)]
639 duplicate_exports: bool,
640
641 #[arg(long)]
643 circular_deps: bool,
644
645 #[arg(long)]
647 re_export_cycles: bool,
648
649 #[arg(long)]
651 boundary_violations: bool,
652
653 #[arg(long)]
655 policy_violations: bool,
656
657 #[arg(long)]
659 stale_suppressions: bool,
660
661 #[arg(long)]
663 unused_catalog_entries: bool,
664
665 #[arg(long)]
667 empty_catalog_groups: bool,
668
669 #[arg(long)]
671 unresolved_catalog_references: bool,
672
673 #[arg(long)]
675 unused_dependency_overrides: bool,
676
677 #[arg(long)]
679 misconfigured_dependency_overrides: bool,
680
681 #[arg(long)]
683 include_dupes: bool,
684
685 #[arg(long, value_name = "FILE:EXPORT")]
687 trace: Option<String>,
688
689 #[arg(long, value_name = "PATH")]
691 trace_file: Option<String>,
692
693 #[arg(long, value_name = "PACKAGE")]
695 trace_dependency: Option<String>,
696
697 #[arg(long, value_name = "PATH")]
701 impact_closure: Option<String>,
702
703 #[arg(long, value_name = "FILE:EXPORT")]
705 symbol_impact: Option<String>,
706
707 #[arg(long)]
709 top: Option<usize>,
710
711 #[arg(long, value_name = "PATH")]
715 file: Vec<std::path::PathBuf>,
716 },
717
718 Watch {
720 #[arg(long)]
722 no_clear: bool,
723 },
724
725 TypeAware {
727 #[command(subcommand)]
728 subcommand: TypeAwareCli,
729 },
730
731 Inspect {
733 #[arg(
735 long,
736 value_name = "PATH",
737 conflicts_with = "symbol",
738 required_unless_present = "symbol"
739 )]
740 file: Option<String>,
741
742 #[arg(long, value_name = "FILE:EXPORT", conflicts_with = "file")]
744 symbol: Option<String>,
745
746 #[arg(long)]
751 symbol_chain: bool,
752
753 #[arg(long)]
756 churn: bool,
757 },
758
759 Trace {
768 #[arg(value_name = "FILE:SYMBOL")]
770 symbol: String,
771
772 #[arg(long)]
775 callers: bool,
776
777 #[arg(long)]
780 callees: bool,
781
782 #[arg(long, value_name = "N")]
785 depth: Option<u32>,
786 },
787
788 Fix {
803 #[arg(long)]
805 dry_run: bool,
806
807 #[arg(long, alias = "force")]
809 yes: bool,
810
811 #[arg(long)]
818 no_create_config: bool,
819 },
820
821 Init {
830 #[arg(long)]
832 toml: bool,
833
834 #[arg(long, conflicts_with_all = ["toml", "hooks", "branch"])]
836 agents: bool,
837
838 #[arg(long)]
842 hooks: bool,
843
844 #[arg(long, requires = "hooks")]
846 branch: Option<String>,
847
848 #[arg(long, conflicts_with_all = ["toml", "agents", "hooks", "branch"])]
852 decline: bool,
853 },
854
855 Hooks {
862 #[command(subcommand)]
863 subcommand: HooksCli,
864 },
865
866 Ci {
868 #[command(subcommand)]
869 subcommand: CiCli,
870 },
871
872 ConfigSchema,
874
875 PluginSchema,
877
878 PluginCheck,
880
881 RulePackSchema,
883
884 RulePack {
886 #[command(subcommand)]
887 subcommand: RulePackCli,
888 },
889
890 Guard {
892 #[arg(required = true, num_args = 1..)]
894 files: Vec<String>,
895 },
896
897 Config {
915 #[arg(long)]
917 path: bool,
918 },
919
920 Recommend,
928
929 List {
931 #[arg(long)]
933 entry_points: bool,
934
935 #[arg(long)]
937 files: bool,
938
939 #[arg(long)]
941 plugins: bool,
942
943 #[arg(long)]
945 boundaries: bool,
946
947 #[arg(long)]
951 workspaces: bool,
952 },
953
954 Workspaces,
960
961 Dupes {
963 #[arg(long)]
966 mode: Option<DupesMode>,
967
968 #[arg(long)]
971 min_tokens: Option<usize>,
972
973 #[arg(long)]
976 min_lines: Option<usize>,
977
978 #[arg(long, value_parser = parse_min_occurrences)]
983 min_occurrences: Option<usize>,
984
985 #[arg(long)]
988 threshold: Option<f64>,
989
990 #[arg(long)]
992 skip_local: bool,
993
994 #[arg(long)]
996 cross_language: bool,
997
998 #[arg(long)]
1002 ignore_imports: bool,
1003
1004 #[arg(long, conflicts_with = "ignore_imports")]
1007 no_ignore_imports: bool,
1008
1009 #[arg(long)]
1012 top: Option<usize>,
1013
1014 #[arg(long, value_name = "FILE:LINE")]
1016 trace: Option<String>,
1017 },
1018
1019 Health {
1025 #[arg(long)]
1027 max_cyclomatic: Option<u16>,
1028
1029 #[arg(long)]
1031 max_cognitive: Option<u16>,
1032
1033 #[arg(long)]
1037 max_crap: Option<f64>,
1038
1039 #[arg(long)]
1041 top: Option<usize>,
1042
1043 #[arg(long, default_value = "cyclomatic")]
1045 sort: SortBy,
1046
1047 #[arg(long)]
1050 complexity: bool,
1051
1052 #[arg(long)]
1059 complexity_breakdown: bool,
1060
1061 #[arg(long)]
1066 file_scores: bool,
1067
1068 #[arg(long)]
1071 coverage_gaps: bool,
1072
1073 #[arg(long)]
1076 hotspots: bool,
1077
1078 #[arg(long)]
1082 ownership: bool,
1083
1084 #[arg(long, value_name = "MODE", value_enum)]
1089 ownership_emails: Option<EmailModeArg>,
1090
1091 #[arg(long)]
1094 targets: bool,
1095
1096 #[arg(long)]
1099 type_coupling: bool,
1100
1101 #[arg(long)]
1106 css: bool,
1107
1108 #[arg(long, value_enum)]
1111 effort: Option<EffortFilter>,
1112
1113 #[arg(long)]
1116 score: bool,
1117
1118 #[arg(long, value_name = "N")]
1127 min_score: Option<f64>,
1128
1129 #[arg(long, value_name = "LEVEL", value_enum)]
1133 min_severity: Option<HealthSeverityCli>,
1134
1135 #[arg(long)]
1139 report_only: bool,
1140
1141 #[arg(long, value_name = "DURATION")]
1144 since: Option<String>,
1145
1146 #[arg(long, value_name = "N")]
1148 min_commits: Option<u32>,
1149
1150 #[expect(
1154 clippy::option_option,
1155 reason = "clap pattern: None=not passed, Some(None)=flag only, Some(Some(path))=with value"
1156 )]
1157 #[arg(long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
1158 save_snapshot: Option<Option<String>>,
1159
1160 #[arg(long)]
1164 trend: bool,
1165
1166 #[arg(long, value_name = "PATH")]
1175 coverage: Option<PathBuf>,
1176
1177 #[arg(long, value_name = "PATH")]
1183 coverage_root: Option<PathBuf>,
1184
1185 #[arg(long, value_name = "PATH")]
1189 runtime_coverage: Option<PathBuf>,
1190
1191 #[arg(long, default_value_t = 100)]
1193 min_invocations_hot: u64,
1194
1195 #[arg(long, value_name = "N")]
1201 min_observation_volume: Option<u32>,
1202
1203 #[arg(long, value_name = "RATIO")]
1208 low_traffic_threshold: Option<f64>,
1209 },
1210
1211 Flags {
1218 #[arg(long)]
1220 top: Option<usize>,
1221 },
1222
1223 Suppressions {
1233 #[arg(long, value_name = "PATH")]
1235 file: Vec<std::path::PathBuf>,
1236 },
1237
1238 Explain {
1244 #[arg(required = true, num_args = 1.., value_name = "ISSUE_TYPE")]
1246 issue_type: Vec<String>,
1247 },
1248
1249 #[command(visible_alias = "review")]
1274 Audit {
1275 #[arg(long = "production-dead-code")]
1277 production_dead_code: bool,
1278
1279 #[arg(long = "production-health")]
1281 production_health: bool,
1282
1283 #[arg(long = "production-dupes")]
1285 production_dupes: bool,
1286
1287 #[arg(long)]
1290 dead_code_baseline: Option<PathBuf>,
1291
1292 #[arg(long)]
1295 health_baseline: Option<PathBuf>,
1296
1297 #[arg(long)]
1300 dupes_baseline: Option<PathBuf>,
1301
1302 #[arg(long)]
1306 max_crap: Option<f64>,
1307
1308 #[arg(long, value_name = "PATH")]
1312 coverage: Option<PathBuf>,
1313
1314 #[arg(long, value_name = "PATH")]
1317 coverage_root: Option<PathBuf>,
1318
1319 #[arg(long = "no-css")]
1321 no_css: bool,
1322
1323 #[arg(long)]
1327 css_deep: bool,
1328
1329 #[arg(long = "no-css-deep")]
1331 no_css_deep: bool,
1332
1333 #[arg(long, value_enum)]
1339 gate: Option<AuditGateArg>,
1340
1341 #[arg(long, value_name = "PATH")]
1350 runtime_coverage: Option<PathBuf>,
1351
1352 #[arg(long, default_value_t = 100)]
1355 min_invocations_hot: u64,
1356
1357 #[arg(long, value_name = "MARKER", hide = true)]
1362 gate_marker: Option<String>,
1363
1364 #[arg(long)]
1370 brief: bool,
1371
1372 #[arg(
1377 long,
1378 value_name = "N",
1379 default_value_t = audit_decision_surface::DEFAULT_DECISION_CAP
1380 )]
1381 max_decisions: usize,
1382
1383 #[arg(long, conflicts_with_all = ["walkthrough_file", "walkthrough"])]
1391 walkthrough_guide: bool,
1392
1393 #[arg(long, value_name = "PATH")]
1401 walkthrough_file: Option<PathBuf>,
1402
1403 #[arg(long, conflicts_with_all = ["walkthrough_guide", "walkthrough_file"])]
1409 walkthrough: bool,
1410
1411 #[arg(long, value_name = "PATH")]
1417 mark_viewed: Vec<PathBuf>,
1418
1419 #[arg(long)]
1423 show_cleared: bool,
1424
1425 #[arg(long)]
1431 show_deprioritized: bool,
1432 },
1433
1434 AuditCache {
1436 #[command(subcommand)]
1437 subcommand: AuditCacheCli,
1438 },
1439
1440 DecisionSurface {
1452 #[arg(
1455 long,
1456 value_name = "N",
1457 default_value_t = audit_decision_surface::DEFAULT_DECISION_CAP
1458 )]
1459 max_decisions: usize,
1460 },
1461
1462 Impact {
1472 #[command(subcommand)]
1473 subcommand: Option<ImpactCli>,
1474 #[arg(long)]
1478 all: bool,
1479 #[arg(long, value_enum, default_value_t = ImpactSortCli::Recent)]
1481 sort: ImpactSortCli,
1482 #[arg(long)]
1485 limit: Option<usize>,
1486 },
1487
1488 Security {
1519 #[command(subcommand)]
1520 subcommand: Option<SecuritySubcommand>,
1521 #[arg(long, value_name = "PATH")]
1526 runtime_coverage: Option<PathBuf>,
1527 #[arg(long, default_value_t = 100)]
1530 min_invocations_hot: u64,
1531 #[arg(long, value_name = "PATH")]
1535 file: Vec<std::path::PathBuf>,
1536 #[arg(long, value_name = "MODE")]
1542 gate: Option<security::SecurityGateArg>,
1543 #[arg(long)]
1545 surface: bool,
1546 },
1547
1548 Report {
1553 #[arg(long, value_name = "PATH")]
1556 from: PathBuf,
1557 },
1558 Schema,
1560
1561 CiTemplate {
1568 #[command(subcommand)]
1569 subcommand: CiTemplateCli,
1570 },
1571
1572 Migrate {
1574 #[arg(long, conflicts_with = "jsonc")]
1576 toml: bool,
1577
1578 #[arg(long)]
1586 jsonc: bool,
1587
1588 #[arg(long)]
1590 dry_run: bool,
1591
1592 #[arg(long, value_name = "PATH")]
1594 from: Option<PathBuf>,
1595 },
1596
1597 License {
1604 #[command(subcommand)]
1605 subcommand: LicenseCli,
1606 },
1607
1608 Telemetry {
1616 #[command(subcommand)]
1617 subcommand: TelemetryCli,
1618 },
1619
1620 Coverage {
1626 #[command(subcommand)]
1627 subcommand: CoverageCli,
1628 },
1629
1630 SetupHooks {
1645 #[arg(long, value_enum)]
1647 agent: Option<setup_hooks::HookAgentArg>,
1648
1649 #[arg(long)]
1651 dry_run: bool,
1652
1653 #[arg(long)]
1656 force: bool,
1657
1658 #[arg(long)]
1660 user: bool,
1661
1662 #[arg(long)]
1664 gitignore_claude: bool,
1665
1666 #[arg(long)]
1670 uninstall: bool,
1671 },
1672
1673 Viz {
1675 #[arg(long = "out", value_name = "PATH")]
1677 output: Option<PathBuf>,
1678
1679 #[arg(long)]
1681 no_open: bool,
1682
1683 #[arg(long = "viz-format", default_value = "html")]
1685 viz_format: viz::VizFormat,
1686 },
1687}
1688
1689#[derive(Subcommand)]
1690enum SecuritySubcommand {
1691 Survivors {
1693 #[arg(long, value_name = "PATH")]
1695 candidates: PathBuf,
1696 #[arg(long, value_name = "PATH")]
1698 verdicts: PathBuf,
1699 #[arg(long)]
1701 require_verdict_for_each_candidate: bool,
1702 },
1703 #[command(name = "blind-spots")]
1705 BlindSpots {
1706 #[arg(long, value_name = "PATH")]
1708 file: Vec<PathBuf>,
1709 },
1710}
1711
1712#[derive(clap::Subcommand)]
1713enum AuditCacheCli {
1714 Remove {
1716 #[arg(long)]
1718 dry_run: bool,
1719
1720 #[arg(long, alias = "force")]
1722 yes: bool,
1723 },
1724}
1725
1726#[derive(clap::Subcommand)]
1727enum LicenseCli {
1728 Activate {
1733 #[arg(value_name = "JWT")]
1735 jwt: Option<String>,
1736
1737 #[arg(long, value_name = "PATH")]
1739 from_file: Option<PathBuf>,
1740
1741 #[arg(long, conflicts_with_all = ["jwt", "from_file"])]
1743 stdin: bool,
1744
1745 #[arg(long, requires = "email")]
1752 trial: bool,
1753
1754 #[arg(long, value_name = "ADDR")]
1756 email: Option<String>,
1757 },
1758 Status,
1760 Refresh,
1762 Deactivate,
1764}
1765
1766#[derive(Clone, Copy, clap::Subcommand)]
1767enum TelemetryCli {
1768 Status,
1770 Enable,
1772 Disable,
1774 Inspect {
1776 #[arg(long)]
1778 example: bool,
1779 },
1780}
1781
1782#[derive(clap::Subcommand)]
1783enum CiTemplateCli {
1784 Gitlab {
1786 #[arg(long, value_name = "DIR", num_args = 0..=1, default_missing_value = ".")]
1790 vendor: Option<PathBuf>,
1791
1792 #[arg(long)]
1794 force: bool,
1795 },
1796}
1797
1798#[derive(clap::Subcommand)]
1799enum CoverageCli {
1800 Setup {
1802 #[arg(short = 'y', long)]
1804 yes: bool,
1805
1806 #[arg(long)]
1808 non_interactive: bool,
1809
1810 #[arg(long)]
1812 json: bool,
1813 },
1814 Analyze {
1820 #[arg(long, value_name = "PATH", conflicts_with = "cloud")]
1822 runtime_coverage: Option<PathBuf>,
1823
1824 #[arg(long, visible_alias = "runtime-coverage-cloud")]
1826 cloud: bool,
1827
1828 #[arg(long, value_name = "KEY")]
1830 api_key: Option<String>,
1831
1832 #[arg(long, value_name = "URL")]
1834 api_endpoint: Option<String>,
1835
1836 #[arg(long, value_name = "OWNER/REPO")]
1842 repo: Option<String>,
1843
1844 #[arg(long, value_name = "ID")]
1846 project_id: Option<String>,
1847
1848 #[arg(long, value_name = "DAYS", default_value_t = 30)]
1850 coverage_period: u16,
1851
1852 #[arg(long, value_name = "ENV")]
1854 environment: Option<String>,
1855
1856 #[arg(long, value_name = "SHA")]
1858 commit_sha: Option<String>,
1859
1860 #[arg(long)]
1862 production: bool,
1863
1864 #[arg(long, default_value_t = 100)]
1866 min_invocations_hot: u64,
1867
1868 #[arg(long, value_name = "N")]
1870 min_observation_volume: Option<u32>,
1871
1872 #[arg(long, value_name = "RATIO")]
1874 low_traffic_threshold: Option<f64>,
1875
1876 #[arg(long)]
1878 top: Option<usize>,
1879
1880 #[arg(long)]
1882 blast_radius: bool,
1883
1884 #[arg(long)]
1886 importance: bool,
1887 },
1888 UploadInventory {
1899 #[arg(long, value_name = "KEY")]
1908 api_key: Option<String>,
1909
1910 #[arg(long, value_name = "URL")]
1915 api_endpoint: Option<String>,
1916
1917 #[arg(long, value_name = "PROJECT_ID")]
1922 project_id: Option<String>,
1923
1924 #[arg(long, value_name = "SHA")]
1929 git_sha: Option<String>,
1930
1931 #[arg(long)]
1937 allow_dirty: bool,
1938
1939 #[arg(long, value_name = "GLOB", num_args = 0..)]
1943 exclude_paths: Vec<String>,
1944
1945 #[arg(long, value_name = "PREFIX")]
1958 path_prefix: Option<String>,
1959
1960 #[arg(long)]
1962 dry_run: bool,
1963
1964 #[arg(long)]
1970 with_callers: bool,
1971
1972 #[arg(long)]
1976 ignore_upload_errors: bool,
1977 },
1978 UploadSourceMaps {
1991 #[arg(long, value_name = "PATH", default_value = "dist")]
1993 dir: PathBuf,
1994
1995 #[arg(long, value_name = "GLOB", default_value = "**/*.map")]
1997 include: String,
1998
1999 #[arg(long, value_name = "GLOB", default_value = "**/node_modules/**")]
2003 exclude: Vec<String>,
2004
2005 #[arg(long, value_name = "NAME")]
2009 repo: Option<String>,
2010
2011 #[arg(long, value_name = "SHA")]
2016 git_sha: Option<String>,
2017
2018 #[arg(long, value_name = "URL")]
2020 endpoint: Option<String>,
2021
2022 #[arg(long, value_name = "BOOL", default_value_t = true, action = clap::ArgAction::Set)]
2027 strip_path: bool,
2028
2029 #[arg(long)]
2031 dry_run: bool,
2032
2033 #[arg(long, value_name = "N", default_value_t = 4)]
2035 concurrency: usize,
2036
2037 #[arg(long)]
2039 fail_fast: bool,
2040 },
2041 UploadStaticFindings {
2048 #[arg(long, value_name = "KEY")]
2058 api_key: Option<String>,
2059
2060 #[arg(long, value_name = "URL")]
2065 api_endpoint: Option<String>,
2066
2067 #[arg(long, value_name = "PROJECT_ID")]
2072 project_id: Option<String>,
2073
2074 #[arg(long, value_name = "SHA")]
2079 git_sha: Option<String>,
2080
2081 #[arg(long)]
2087 allow_dirty: bool,
2088
2089 #[arg(long)]
2091 dry_run: bool,
2092
2093 #[arg(long)]
2097 ignore_upload_errors: bool,
2098 },
2099}
2100
2101#[derive(Subcommand)]
2102enum CiCli {
2103 PlanPrComment {
2105 #[arg(long)]
2107 body: PathBuf,
2108
2109 #[arg(long)]
2111 marker_id: String,
2112
2113 #[arg(long)]
2115 clean: bool,
2116
2117 #[arg(long)]
2119 existing_comment_id: Option<String>,
2120
2121 #[arg(long)]
2123 existing_body: Option<PathBuf>,
2124 },
2125
2126 PostPrComment {
2128 #[arg(long, value_enum)]
2130 provider: CiProviderArg,
2131
2132 #[arg(long)]
2134 pr: Option<String>,
2135
2136 #[arg(long)]
2138 mr: Option<String>,
2139
2140 #[arg(long)]
2142 body: PathBuf,
2143
2144 #[arg(long)]
2146 envelope: Option<PathBuf>,
2147
2148 #[arg(long)]
2150 marker_id: String,
2151
2152 #[arg(long)]
2154 clean: bool,
2155
2156 #[arg(long)]
2158 repo: Option<String>,
2159
2160 #[arg(long = "project-id")]
2162 project_id: Option<String>,
2163
2164 #[arg(long = "api-url")]
2166 api_url: Option<String>,
2167
2168 #[arg(long)]
2170 dry_run: bool,
2171 },
2172
2173 PostReview {
2175 #[arg(long, value_enum)]
2177 provider: CiProviderArg,
2178
2179 #[arg(long)]
2181 pr: Option<String>,
2182
2183 #[arg(long)]
2185 mr: Option<String>,
2186
2187 #[arg(long)]
2189 envelope: PathBuf,
2190
2191 #[arg(long)]
2193 repo: Option<String>,
2194
2195 #[arg(long = "project-id")]
2197 project_id: Option<String>,
2198
2199 #[arg(long = "api-url")]
2201 api_url: Option<String>,
2202
2203 #[arg(long)]
2205 dry_run: bool,
2206 },
2207
2208 PostCheckRun {
2210 #[arg(long, value_enum)]
2212 provider: CiProviderArg,
2213
2214 #[arg(long)]
2216 decision: PathBuf,
2217
2218 #[arg(long)]
2220 repo: String,
2221
2222 #[arg(long = "head-sha")]
2224 head_sha: String,
2225
2226 #[arg(long = "api-url")]
2228 api_url: Option<String>,
2229
2230 #[arg(long = "split-gates")]
2232 split_gates: bool,
2233
2234 #[arg(long)]
2236 dry_run: bool,
2237 },
2238
2239 ReconcileReview {
2241 #[arg(long, value_enum)]
2243 provider: CiProviderArg,
2244
2245 #[arg(long)]
2247 pr: Option<String>,
2248
2249 #[arg(long)]
2251 mr: Option<String>,
2252
2253 #[arg(long)]
2255 envelope: PathBuf,
2256
2257 #[arg(long)]
2259 repo: Option<String>,
2260
2261 #[arg(long = "project-id")]
2263 project_id: Option<String>,
2264
2265 #[arg(long = "api-url")]
2267 api_url: Option<String>,
2268
2269 #[arg(long)]
2271 dry_run: bool,
2272 },
2273}
2274
2275#[derive(Subcommand)]
2276enum RulePackCli {
2277 Init {
2279 name: Option<String>,
2281
2282 #[arg(long, default_value = "starter")]
2284 template: String,
2285
2286 #[arg(long, default_value = "rule-packs")]
2288 dir: String,
2289
2290 #[arg(long)]
2292 no_config: bool,
2293 },
2294
2295 List,
2297
2298 Test {
2300 pack: Option<PathBuf>,
2302 },
2303
2304 Schema,
2306}
2307
2308#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
2310pub enum BaselineModeArg {
2311 #[default]
2313 Count,
2314 Identity,
2317}
2318
2319impl From<BaselineModeArg> for fallow_engine::baseline::HealthBaselineMode {
2320 fn from(value: BaselineModeArg) -> Self {
2321 match value {
2322 BaselineModeArg::Count => Self::Count,
2323 BaselineModeArg::Identity => Self::Identity,
2324 }
2325 }
2326}
2327
2328#[derive(Clone, Copy, Debug, clap::ValueEnum)]
2329enum CiProviderArg {
2330 Github,
2331 Gitlab,
2332}
2333
2334#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2336enum TypeAwareRequireArg {
2337 BestEffort,
2339 Complete,
2341}
2342
2343impl From<TypeAwareRequireArg> for fallow_config::TypeAwareRequire {
2344 fn from(value: TypeAwareRequireArg) -> Self {
2345 match value {
2346 TypeAwareRequireArg::BestEffort => Self::BestEffort,
2347 TypeAwareRequireArg::Complete => Self::Complete,
2348 }
2349 }
2350}
2351
2352#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2354pub enum EffortFilter {
2355 Low,
2356 Medium,
2357 High,
2358}
2359
2360impl EffortFilter {
2361 const fn to_estimate(self) -> fallow_output::EffortEstimate {
2363 match self {
2364 Self::Low => fallow_output::EffortEstimate::Low,
2365 Self::Medium => fallow_output::EffortEstimate::Medium,
2366 Self::High => fallow_output::EffortEstimate::High,
2367 }
2368 }
2369}
2370
2371#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2373pub enum HealthSeverityCli {
2374 Moderate,
2375 High,
2376 Critical,
2377}
2378
2379impl HealthSeverityCli {
2380 const fn to_health_severity(self) -> fallow_output::FindingSeverity {
2382 match self {
2383 Self::Moderate => fallow_output::FindingSeverity::Moderate,
2384 Self::High => fallow_output::FindingSeverity::High,
2385 Self::Critical => fallow_output::FindingSeverity::Critical,
2386 }
2387 }
2388}
2389
2390#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2396pub enum EmailModeArg {
2397 Raw,
2399 Handle,
2401 Anonymized,
2403 #[value(hide = true)]
2405 Hash,
2406}
2407
2408impl EmailModeArg {
2409 const fn to_config(self) -> fallow_config::EmailMode {
2411 match self {
2412 Self::Raw => fallow_config::EmailMode::Raw,
2413 Self::Handle => fallow_config::EmailMode::Handle,
2414 Self::Anonymized => fallow_config::EmailMode::Anonymized,
2415 Self::Hash => fallow_config::EmailMode::Hash,
2416 }
2417 }
2418}
2419
2420#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2422pub enum AuditGateArg {
2423 NewOnly,
2425 All,
2427}
2428
2429impl From<AuditGateArg> for fallow_config::AuditGate {
2430 fn from(value: AuditGateArg) -> Self {
2431 match value {
2432 AuditGateArg::NewOnly => Self::NewOnly,
2433 AuditGateArg::All => Self::All,
2434 }
2435 }
2436}
2437
2438fn parse_min_occurrences(s: &str) -> Result<usize, String> {
2442 let value: usize = s
2443 .parse()
2444 .map_err(|_| format!("`{s}` is not a non-negative integer"))?;
2445 if value < 2 {
2446 return Err(format!(
2447 "must be at least 2 (got {value}); a single occurrence isn't a duplicate"
2448 ));
2449 }
2450 Ok(value)
2451}
2452
2453fn resolve_audit_baseline_path(
2459 root: &std::path::Path,
2460 cli: Option<&std::path::Path>,
2461 config: Option<&str>,
2462) -> Option<PathBuf> {
2463 let path = cli.map(std::path::Path::to_path_buf).or_else(|| {
2464 config.map(|p| {
2465 let path = PathBuf::from(p);
2466 if path_util::is_absolute_path_any_platform(&path) {
2467 path
2468 } else {
2469 root.join(path)
2470 }
2471 })
2472 })?;
2473 if path_util::is_absolute_path_any_platform(&path) {
2474 Some(path)
2475 } else {
2476 Some(root.join(path))
2477 }
2478}
2479
2480fn emit_known_failure(
2481 message: &str,
2482 exit_code: u8,
2483 output: fallow_config::OutputFormat,
2484 reason: telemetry::FailureReason,
2485) -> ExitCode {
2486 telemetry::note_failure_reason(reason);
2487 emit_error(message, exit_code, output)
2488}
2489
2490fn emit_known_failure_with_style(
2491 message: &str,
2492 exit_code: u8,
2493 output: fallow_config::OutputFormat,
2494 json_style: json_style::JsonStyle,
2495 reason: telemetry::FailureReason,
2496) -> ExitCode {
2497 telemetry::note_failure_reason(reason);
2498 error::emit_error_with_style(message, exit_code, output, json_style)
2499}
2500
2501fn unsupported_security_global(cli: &Cli) -> Option<&'static str> {
2502 if cli.baseline.is_some() {
2503 Some("--baseline")
2504 } else if cli.save_baseline.is_some() {
2505 Some("--save-baseline")
2506 } else if cli.production {
2507 Some("--production")
2508 } else if cli.no_production {
2509 Some("--no-production")
2510 } else if cli.group_by.is_some() {
2511 Some("--group-by")
2512 } else if cli.performance {
2513 Some("--performance")
2514 } else if cli.explain_skipped {
2515 Some("--explain-skipped")
2516 } else if cli.fail_on_regression {
2517 Some("--fail-on-regression")
2518 } else if cli.regression_baseline.is_some() {
2519 Some("--regression-baseline")
2520 } else if cli.save_regression_baseline.is_some() {
2521 Some("--save-regression-baseline")
2522 } else if cli.dupes_mode.is_some() {
2523 Some("--dupes-mode")
2524 } else if cli.dupes_threshold.is_some() {
2525 Some("--dupes-threshold")
2526 } else if cli.dupes_min_tokens.is_some() {
2527 Some("--dupes-min-tokens")
2528 } else if cli.dupes_min_lines.is_some() {
2529 Some("--dupes-min-lines")
2530 } else if cli.dupes_min_occurrences.is_some() {
2531 Some("--dupes-min-occurrences")
2532 } else if cli.dupes_skip_local {
2533 Some("--dupes-skip-local")
2534 } else if cli.dupes_cross_language {
2535 Some("--dupes-cross-language")
2536 } else if cli.dupes_ignore_imports {
2537 Some("--dupes-ignore-imports")
2538 } else if cli.dupes_no_ignore_imports {
2539 Some("--dupes-no-ignore-imports")
2540 } else if cli.include_entry_exports {
2541 Some("--include-entry-exports")
2542 } else {
2543 None
2544 }
2545}
2546
2547struct DispatchContext<'a> {
2548 cli: &'a Cli,
2549 root: &'a std::path::Path,
2550 output: fallow_config::OutputFormat,
2551 quiet: bool,
2552 fail_on_issues: bool,
2553 json_style: json_style::JsonStyle,
2554 threads: usize,
2555 tolerance: regression::Tolerance,
2556 save_regression_file: Option<&'a std::path::PathBuf>,
2557 save_to_config: bool,
2558}
2559
2560impl DispatchContext<'_> {
2561 fn production_modes(
2562 &self,
2563 dead_code: bool,
2564 health: bool,
2565 dupes: bool,
2566 ) -> Result<ProductionModes, ExitCode> {
2567 resolve_production_modes(self.cli, self.root, self.output, dead_code, health, dupes)
2568 }
2569
2570 fn production_for(
2571 &self,
2572 analysis: fallow_config::ProductionAnalysis,
2573 ) -> Result<bool, ExitCode> {
2574 self.production_modes(false, false, false)
2575 .map(|modes| modes.for_analysis(analysis))
2576 }
2577
2578 fn regression_opts(&self, scoped: bool) -> regression::RegressionOpts<'_> {
2579 regression::RegressionOpts {
2580 fail_on_regression: self.cli.fail_on_regression,
2581 tolerance: self.tolerance,
2582 regression_baseline_file: self.cli.regression_baseline.as_deref(),
2583 save_target: if let Some(path) = self.save_regression_file {
2584 regression::SaveRegressionTarget::File(path)
2585 } else if self.save_to_config {
2586 regression::SaveRegressionTarget::Config
2587 } else {
2588 regression::SaveRegressionTarget::None
2589 },
2590 scoped,
2591 quiet: self.quiet,
2592 output: self.output,
2593 }
2594 }
2595}
2596
2597#[cfg(unix)]
2612fn signal_test_helper() -> ExitCode {
2613 use std::io::Write as _;
2614 use std::process::Command;
2615
2616 if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER_GRACEFUL").is_some() {
2617 signal::set_graceful_mode();
2618 }
2619
2620 let mut command = Command::new("sleep");
2621 command.arg("30");
2622 let child = match signal::ScopedChild::spawn(&mut command) {
2623 Ok(c) => c,
2624 Err(err) => {
2625 let _ = writeln!(std::io::stderr(), "spawn sleep failed: {err}");
2626 return ExitCode::from(2);
2627 }
2628 };
2629 let pid = child.id();
2630 let stdout = std::io::stdout();
2631 let mut lock = stdout.lock();
2632 let _ = writeln!(lock, "{pid}");
2633 let _ = lock.flush();
2634 drop(lock);
2635 let _ = child.wait_with_output();
2636 if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER_GRACEFUL").is_some() {
2637 return ExitCode::SUCCESS;
2638 }
2639 std::thread::sleep(std::time::Duration::from_secs(5));
2640 ExitCode::SUCCESS
2641}
2642
2643#[cfg(not(unix))]
2644fn signal_test_helper() -> ExitCode {
2645 ExitCode::from(2)
2646}
2647
2648fn install_spawn_hooks() {
2649 fallow_engine::churn::set_spawn_hook(signal::scoped_child::output);
2650 fallow_engine::changed_files::set_spawn_hook(signal::scoped_child::output);
2651}
2652
2653fn install_signal_handlers() {
2654 if let Err(err) = signal::install_handlers() {
2655 use std::io::Write as _;
2656 let stderr = std::io::stderr();
2657 let mut lock = stderr.lock();
2658 let _ = writeln!(lock, "fallow: failed to install signal handlers: {err}");
2659 }
2660}
2661
2662fn redirect_report_to_file(
2667 path: &std::path::Path,
2668 output: fallow_config::OutputFormat,
2669) -> Result<(), ExitCode> {
2670 if let Some(parent) = path.parent()
2671 && !parent.as_os_str().is_empty()
2672 && let Err(e) = std::fs::create_dir_all(parent)
2673 {
2674 return Err(emit_error(
2675 &format!(
2676 "failed to create {} for --output-file: {e}",
2677 parent.display()
2678 ),
2679 2,
2680 output,
2681 ));
2682 }
2683 match std::fs::File::create(path) {
2684 Ok(file) => {
2685 report::sink::set_file_sink(file);
2686 colored::control::set_override(false);
2687 Ok(())
2688 }
2689 Err(e) => Err(emit_error(
2690 &format!("failed to open {} for --output-file: {e}", path.display()),
2691 2,
2692 output,
2693 )),
2694 }
2695}
2696
2697fn finalize_report_file(
2700 path: &std::path::Path,
2701 quiet: bool,
2702 output: fallow_config::OutputFormat,
2703) -> Result<(), ExitCode> {
2704 if let Err(e) = report::sink::flush() {
2705 return Err(emit_error(
2706 &format!("failed to write {}: {e}", path.display()),
2707 2,
2708 output,
2709 ));
2710 }
2711 if !quiet && report::sink::wrote() {
2715 eprintln!("Report written to {}", path.display());
2716 }
2717 Ok(())
2718}
2719
2720pub fn run() -> ExitCode {
2725 install_signal_handlers();
2726 install_spawn_hooks();
2727
2728 if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER").is_some() {
2729 return signal_test_helper();
2730 }
2731
2732 let (mut cli, fmt) = match parse_cli_args() {
2733 Ok(parsed) => parsed,
2734 Err(code) => return code,
2735 };
2736 if cli.pretty && !fmt.payload_is_json {
2737 eprintln!(
2738 "Error: --pretty requires JSON output. Use --format json --pretty, or remove --pretty."
2739 );
2740 return ExitCode::from(2);
2741 }
2742
2743 if let Some(code) = run_schema_command_if_requested(&cli, fmt.json_style) {
2744 return code;
2745 }
2746
2747 if let Some(code) = run_telemetry_command_if_requested(&mut cli, fmt.output, fmt.json_style) {
2748 return code;
2749 }
2750 if is_impact_statusline(&cli) {
2751 let (root, _) = match validate_inputs(&cli, fmt.output, fmt.json_style) {
2752 Ok(validated) => validated,
2753 Err(code) => return code,
2754 };
2755 return cli_impact::render_impact_statusline(&root);
2756 }
2757 let telemetry_run = start_telemetry_run(&cli, &fmt);
2758
2759 let (root, threads) = match validate_inputs(&cli, fmt.output, fmt.json_style) {
2760 Ok(v) => v,
2761 Err(code) => {
2762 return record_run_epilogue(telemetry_run, code, None, cli.parent_run.as_deref());
2763 }
2764 };
2765
2766 let FormatConfig {
2767 output,
2768 payload_is_json: _,
2769 quiet,
2770 fail_on_issues,
2771 json_style,
2772 } = fmt;
2773
2774 let tolerance =
2775 match run_pre_dispatch_checks(&cli, &root, output, json_style, quiet, telemetry_run) {
2776 Ok(tolerance) => tolerance,
2777 Err(code) => return code,
2778 };
2779
2780 let (save_regression_file, save_to_config) = regression_save_targets(&cli);
2781
2782 let command = cli.command.take();
2783 let dispatch = DispatchContext {
2784 cli: &cli,
2785 root: &root,
2786 output,
2787 quiet,
2788 fail_on_issues,
2789 json_style,
2790 threads,
2791 tolerance,
2792 save_regression_file: save_regression_file.as_ref(),
2793 save_to_config,
2794 };
2795 let exit_code = match dispatch_and_finalize(&dispatch, command) {
2796 Ok(code) => code,
2797 Err(code) => return code,
2798 };
2799 record_run_epilogue(telemetry_run, exit_code, None, cli.parent_run.as_deref())
2800}
2801
2802fn is_impact_statusline(cli: &Cli) -> bool {
2805 matches!(
2806 cli.command.as_ref(),
2807 Some(Command::Impact {
2808 subcommand: Some(ImpactCli::Statusline),
2809 all: false,
2810 ..
2811 })
2812 )
2813}
2814
2815fn dispatch_and_finalize(
2819 dispatch: &DispatchContext<'_>,
2820 command: Option<Command>,
2821) -> Result<ExitCode, ExitCode> {
2822 let cli = dispatch.cli;
2823 let output = dispatch.output;
2824 let quiet = dispatch.quiet;
2825
2826 if let Some(path) = cli.output_file.as_deref()
2829 && let Err(code) = redirect_report_to_file(path, output)
2830 {
2831 return Err(code);
2832 }
2833
2834 let exit_code = if command.is_some() && cli_has_bare_coverage_input(cli) {
2835 emit_error(bare_coverage_subcommand_error_message(), 2, output)
2836 } else {
2837 match command {
2838 None => dispatch_bare_command(dispatch),
2839 Some(cmd) => dispatch_subcommand(cmd, dispatch),
2840 }
2841 };
2842
2843 if let Some(path) = cli.output_file.as_deref()
2844 && let Err(code) = finalize_report_file(path, quiet, output)
2845 {
2846 return Err(code);
2847 }
2848 Ok(exit_code)
2849}
2850
2851fn run_telemetry_command_if_requested(
2852 cli: &mut Cli,
2853 output: fallow_config::OutputFormat,
2854 json_style: json_style::JsonStyle,
2855) -> Option<ExitCode> {
2856 if matches!(cli.command, Some(Command::Telemetry { .. }))
2857 && let Some(Command::Telemetry { subcommand }) = cli.command.take()
2858 {
2859 return Some(telemetry::run(
2860 map_telemetry_subcommand(subcommand),
2861 output,
2862 json_style,
2863 ));
2864 }
2865 None
2866}
2867
2868fn run_schema_command_if_requested(
2869 cli: &Cli,
2870 json_style: json_style::JsonStyle,
2871) -> Option<ExitCode> {
2872 match cli.command {
2873 Some(Command::Schema) => Some(schema::run_schema(json_style)),
2874 Some(Command::ConfigSchema) => Some(init::run_config_schema(json_style)),
2875 Some(Command::PluginSchema) => Some(init::run_plugin_schema(json_style)),
2876 Some(Command::RulePackSchema) => Some(init::run_rule_pack_schema(json_style)),
2877 _ => None,
2878 }
2879}
2880
2881fn regression_save_targets(cli: &Cli) -> (Option<std::path::PathBuf>, bool) {
2882 let save_file = cli.save_regression_baseline.as_ref().and_then(|opt| {
2883 opt.as_ref()
2884 .filter(|path| !path.is_empty())
2885 .map(std::path::PathBuf::from)
2886 });
2887 let save_to_config = cli.save_regression_baseline.is_some() && save_file.is_none();
2888 (save_file, save_to_config)
2889}
2890
2891fn dispatch_bare_command(dispatch: &DispatchContext<'_>) -> ExitCode {
2892 let cli = dispatch.cli;
2893 let (run_check, run_dupes, run_health) = combined::resolve_analyses(&cli.only, &cli.skip);
2894 let production = match dispatch.production_modes(
2895 cli.production_dead_code,
2896 cli.production_health,
2897 cli.production_dupes,
2898 ) {
2899 Ok(production) => production,
2900 Err(code) => return code,
2901 };
2902 let coverage_inputs = match resolve_health_coverage_inputs(
2903 dispatch,
2904 cli.coverage.as_deref(),
2905 cli.coverage_root.as_deref(),
2906 ) {
2907 Ok(inputs) => inputs,
2908 Err(code) => return code,
2909 };
2910 run_bare_combined(
2911 dispatch,
2912 production,
2913 &coverage_inputs,
2914 BareAnalyses {
2915 run_check,
2916 run_dupes,
2917 run_health,
2918 },
2919 )
2920}
2921
2922#[derive(Clone, Copy)]
2924struct BareAnalyses {
2925 run_check: bool,
2926 run_dupes: bool,
2927 run_health: bool,
2928}
2929
2930fn run_bare_combined(
2933 dispatch: &DispatchContext<'_>,
2934 production: ProductionModes,
2935 coverage_inputs: &ResolvedHealthCoverageInputs,
2936 analyses: BareAnalyses,
2937) -> ExitCode {
2938 let cli = dispatch.cli;
2939 let (output, quiet, fail_on_issues) =
2940 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
2941 combined::run_combined(&combined::CombinedOptions {
2942 root: dispatch.root,
2943 config_path: &cli.config,
2944 output,
2945 json_style: dispatch.json_style,
2946 no_cache: cli.no_cache,
2947 threads: dispatch.threads,
2948 quiet,
2949 allow_remote_extends: cli.allow_remote_extends,
2950 fail_on_issues,
2951 sarif_file: cli.sarif_file.as_deref(),
2952 changed_since: cli.changed_since.as_deref(),
2953 churn_file: cli.churn_file.as_deref(),
2954 baseline: cli.baseline.as_deref(),
2955 save_baseline: cli.save_baseline.as_deref(),
2956 production: cli.production,
2957 production_dead_code: Some(production.dead_code),
2958 production_health: Some(production.health),
2959 production_dupes: Some(production.dupes),
2960 workspace: cli.workspace.as_deref(),
2961 changed_workspaces: cli.changed_workspaces.as_deref(),
2962 group_by: cli.group_by,
2963 type_aware: cli.type_aware_override(),
2964 type_aware_projects: &cli.type_aware_project,
2965 type_aware_require: cli.type_aware_require.map(Into::into),
2966 explain: cli.explain,
2967 explain_skipped: cli.explain_skipped,
2968 performance: cli.performance,
2969 summary: cli.summary,
2970 run_check: analyses.run_check,
2971 run_dupes: analyses.run_dupes,
2972 run_health: analyses.run_health,
2973 dupes_mode: cli.dupes_mode,
2974 dupes_threshold: cli.dupes_threshold,
2975 dupes_min_tokens: cli.dupes_min_tokens,
2976 dupes_min_lines: cli.dupes_min_lines,
2977 dupes_min_occurrences: cli.dupes_min_occurrences,
2978 dupes_skip_local: cli.dupes_skip_local,
2979 dupes_cross_language: cli.dupes_cross_language,
2980 dupes_ignore_imports: resolve_ignore_imports(
2981 cli.dupes_ignore_imports,
2982 cli.dupes_no_ignore_imports,
2983 ),
2984 score: cli.score || cli.trend,
2985 trend: cli.trend,
2986 save_snapshot: cli.save_snapshot.as_ref(),
2987 coverage: coverage_inputs.coverage.as_deref(),
2988 coverage_root: coverage_inputs.coverage_root.as_deref(),
2989 include_entry_exports: cli.include_entry_exports,
2990 regression_opts: dispatch.regression_opts(
2991 cli.changed_since.is_some()
2992 || cli.workspace.is_some()
2993 || cli.changed_workspaces.is_some(),
2994 ),
2995 })
2996}
2997
2998fn dispatch_subcommand(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
2999 let cli = dispatch.cli;
3000 let root = dispatch.root;
3001 let output = dispatch.output;
3002 let quiet = dispatch.quiet;
3003 match command {
3004 check @ Command::Check { .. } => dispatch_check_command(check, dispatch),
3005 Command::Watch { no_clear } => dispatch_watch(dispatch, no_clear),
3006 Command::TypeAware { subcommand } => dispatch_type_aware_command(dispatch, subcommand),
3007 Command::Inspect {
3008 file,
3009 symbol,
3010 symbol_chain,
3011 churn,
3012 } => dispatch_inspect_command(dispatch, file, symbol, symbol_chain, churn),
3013 Command::Trace {
3014 symbol,
3015 callers,
3016 callees,
3017 depth,
3018 } => dispatch_trace_command(dispatch, symbol, callers, callees, depth),
3019 fix @ Command::Fix { .. } => dispatch_fix_command(&fix, dispatch),
3020 init @ Command::Init { .. } => dispatch_init_command(init, root, quiet),
3021 Command::Hooks { subcommand } => {
3022 run_hooks_command(root, subcommand, output, dispatch.json_style)
3023 }
3024 Command::Ci { subcommand } => {
3025 ci::run(map_ci_subcommand(subcommand), output, dispatch.json_style)
3026 }
3027 Command::ConfigSchema => init::run_config_schema(dispatch.json_style),
3028 Command::PluginSchema => init::run_plugin_schema(dispatch.json_style),
3029 Command::PluginCheck => plugin_check::run_plugin_check(root, output, dispatch.json_style),
3030 Command::RulePackSchema => init::run_rule_pack_schema(dispatch.json_style),
3031 Command::RulePack { subcommand } => dispatch_rule_pack_command(dispatch, subcommand),
3032 Command::Guard { files } => dispatch_guard_command(dispatch, &files),
3033 Command::CiTemplate { subcommand } => dispatch_ci_template_command(subcommand),
3034 Command::Config { path } => config::run_config_with_options(config::RunConfigInput {
3035 root,
3036 explicit_config: cli.config.as_deref(),
3037 path_only: path,
3038 output,
3039 quiet,
3040 json_style: dispatch.json_style,
3041 load_options: fallow_config::ConfigLoadOptions {
3042 allow_remote_extends: cli.allow_remote_extends,
3043 },
3044 }),
3045 Command::Recommend => onboarding::run_recommend(root, output, dispatch.json_style),
3046 list @ (Command::Workspaces | Command::List { .. }) => {
3047 dispatch_list_command(&list, dispatch)
3048 }
3049 dupes @ Command::Dupes { .. } => dispatch_dupes_command(dupes, dispatch),
3050 health @ Command::Health { .. } => dispatch_health_command(health, dispatch),
3051 Command::Flags { top } => dispatch_flags_command(dispatch, top),
3052 Command::Suppressions { file } => dispatch_suppressions_command(dispatch, &file),
3053 Command::Explain { issue_type } => {
3054 explain::run_explain(&issue_type.join(" "), output, dispatch.json_style)
3055 }
3056 audit @ Command::Audit { .. } => dispatch_audit_command(audit, dispatch),
3057 Command::AuditCache { subcommand } => dispatch_audit_cache_command(dispatch, &subcommand),
3058 Command::DecisionSurface { max_decisions } => {
3059 dispatch_decision_surface(dispatch, max_decisions)
3060 }
3061 Command::Impact {
3062 subcommand,
3063 all,
3064 sort,
3065 limit,
3066 } => dispatch_impact(
3067 root,
3068 quiet,
3069 output,
3070 dispatch.json_style,
3071 subcommand,
3072 ImpactCrossRepoOpts { all, sort, limit },
3073 ),
3074 security @ Command::Security { .. } => dispatch_security_command(security, dispatch),
3075 Command::Viz {
3076 output: viz_output,
3077 no_open,
3078 viz_format,
3079 } => dispatch_viz(dispatch, viz_output.as_deref(), no_open, viz_format),
3080 Command::Report { from } => {
3081 cli_report::run_report(&from, output, root, cli.config.as_deref())
3082 }
3083 Command::Schema => unreachable!("handled above"),
3084 migrate @ Command::Migrate { .. } => dispatch_migrate_command(migrate, root),
3085 Command::License { subcommand } => {
3086 dispatch_license_command(subcommand, output, dispatch.json_style)
3087 }
3088 Command::Telemetry { .. } => unreachable!("handled before root validation"),
3089 Command::Coverage { subcommand } => dispatch_coverage_command(dispatch, &subcommand),
3090 setup_hooks @ Command::SetupHooks { .. } => {
3091 dispatch_setup_hooks_command(&setup_hooks, dispatch)
3092 }
3093 }
3094}
3095
3096fn dispatch_type_aware_command(
3097 dispatch: &DispatchContext<'_>,
3098 subcommand: TypeAwareCli,
3099) -> ExitCode {
3100 match subcommand {
3101 TypeAwareCli::Status => {
3102 let status = fallow_api::type_aware_status(dispatch.root);
3103 match dispatch.output {
3104 fallow_config::OutputFormat::Json => {
3105 let output = type_aware_status_output(dispatch.root, status);
3106 match fallow_output::serialize_type_aware_status_json_output(
3107 output,
3108 crate::output_runtime::current_root_envelope_mode(),
3109 ) {
3110 Ok(value) => match dispatch.json_style.serialize(&value) {
3111 Ok(json) => {
3112 crate::report::sink::outln!("{json}");
3113 ExitCode::SUCCESS
3114 }
3115 Err(error) => emit_error(
3116 &format!("failed to serialize type-aware status: {error}"),
3117 2,
3118 dispatch.output,
3119 ),
3120 },
3121 Err(error) => emit_error(
3122 &format!("failed to build type-aware status: {error}"),
3123 2,
3124 dispatch.output,
3125 ),
3126 }
3127 }
3128 fallow_config::OutputFormat::Human => {
3129 if status.available {
3130 crate::report::sink::outln!(
3131 "{}",
3132 report::human_status_line(
3133 report::HumanStatus::Ok,
3134 format_args!(
3135 "Type-aware companion: available ({}, protocol {}, TypeScript {})",
3136 status.package_version.as_deref().unwrap_or("unknown"),
3137 status.protocol_version,
3138 status.backend_version.as_deref().unwrap_or("unknown"),
3139 )
3140 )
3141 );
3142 } else {
3143 crate::report::sink::outln!(
3144 "{}",
3145 report::human_status_line(
3146 report::HumanStatus::Inactive,
3147 "Type-aware companion: unavailable"
3148 )
3149 );
3150 if let Some(remediation) = status.remediation {
3151 crate::report::sink::outln!(
3152 "{}",
3153 report::human_status_line(
3154 report::HumanStatus::Warning,
3155 format_args!("Action: {remediation}")
3156 )
3157 );
3158 }
3159 }
3160 ExitCode::SUCCESS
3161 }
3162 _ => emit_error(
3163 "type-aware status supports human and json output",
3164 2,
3165 dispatch.output,
3166 ),
3167 }
3168 }
3169 }
3170}
3171
3172fn type_aware_status_output(
3173 root: &Path,
3174 status: fallow_api::TypeAwareStatus,
3175) -> fallow_output::TypeAwareStatusOutput {
3176 let companion_path = status.companion_path.as_deref().map(|path| {
3177 if let Ok(relative) = path.strip_prefix(root)
3178 && !relative.as_os_str().is_empty()
3179 {
3180 relative.to_string_lossy().replace('\\', "/")
3181 } else {
3182 path.file_name()
3183 .unwrap_or(path.as_os_str())
3184 .to_string_lossy()
3185 .into_owned()
3186 }
3187 });
3188 let remediation = status.remediation.map(|message| {
3189 let without_root = message.replace(root.to_string_lossy().as_ref(), ".");
3190 status.companion_path.as_deref().map_or_else(
3191 || without_root.clone(),
3192 |path| {
3193 without_root.replace(
3194 path.to_string_lossy().as_ref(),
3195 companion_path.as_deref().unwrap_or("fallow-type-aware"),
3196 )
3197 },
3198 )
3199 });
3200 fallow_output::TypeAwareStatusOutput {
3201 schema_version: fallow_types::envelope::SchemaVersion(report::SCHEMA_VERSION),
3202 version: fallow_types::envelope::ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
3203 available: status.available,
3204 discovery_source: status.discovery_source.map(str::to_string),
3205 companion_path,
3206 package_version: status.package_version,
3207 protocol_version: status.protocol_version,
3208 backend_family: status.backend_family,
3209 backend_version: status.backend_version,
3210 remediation,
3211 }
3212}
3213
3214fn dispatch_check_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3216 let filters = check_issue_filters(&command);
3217 let Command::Check {
3218 include_dupes,
3219 trace,
3220 trace_file,
3221 trace_dependency,
3222 impact_closure,
3223 symbol_impact,
3224 top,
3225 file,
3226 ..
3227 } = command
3228 else {
3229 unreachable!("check dispatcher only handles check commands");
3230 };
3231
3232 dispatch_check(
3233 dispatch,
3234 &CheckDispatchArgs {
3235 filters,
3236 trace_opts: TraceOptions {
3237 trace_export: trace,
3238 trace_file,
3239 trace_dependency,
3240 impact_closure,
3241 symbol_impact,
3242 performance: dispatch.cli.performance,
3243 },
3244 include_dupes,
3245 type_aware: dispatch.cli.type_aware_override(),
3246 type_aware_project: dispatch.cli.type_aware_project.clone(),
3247 type_aware_require: dispatch.cli.type_aware_require,
3248 top,
3249 file,
3250 },
3251 )
3252}
3253
3254fn check_issue_filters(command: &Command) -> IssueFilters {
3259 check_issue_filters_framework(command, &check_issue_filters_core(command))
3260}
3261
3262fn check_issue_filters_core(command: &Command) -> IssueFilters {
3265 let Command::Check {
3266 unused_files,
3267 unused_exports,
3268 unused_deps,
3269 unused_types,
3270 private_type_leaks,
3271 unused_enum_members,
3272 unused_class_members,
3273 unresolved_imports,
3274 unlisted_deps,
3275 duplicate_exports,
3276 circular_deps,
3277 re_export_cycles,
3278 boundary_violations,
3279 policy_violations,
3280 stale_suppressions,
3281 ..
3282 } = command
3283 else {
3284 unreachable!("check filter builder only handles check commands");
3285 };
3286
3287 let mut filters = IssueFilters::default();
3288 for (flag, active) in [
3289 ("--unused-files", *unused_files),
3290 ("--unused-exports", *unused_exports),
3291 ("--unused-deps", *unused_deps),
3292 ("--unused-types", *unused_types),
3293 ("--private-type-leaks", *private_type_leaks),
3294 ("--unused-enum-members", *unused_enum_members),
3295 ("--unused-class-members", *unused_class_members),
3296 ("--unresolved-imports", *unresolved_imports),
3297 ("--unlisted-deps", *unlisted_deps),
3298 ("--duplicate-exports", *duplicate_exports),
3299 ("--circular-deps", *circular_deps),
3300 ("--re-export-cycles", *re_export_cycles),
3301 ("--boundary-violations", *boundary_violations),
3302 ("--policy-violations", *policy_violations),
3303 ("--stale-suppressions", *stale_suppressions),
3304 ] {
3305 enable_check_filter(&mut filters, flag, active);
3306 }
3307 filters
3308}
3309
3310fn check_issue_filters_framework(command: &Command, base: &IssueFilters) -> IssueFilters {
3313 let Command::Check {
3314 unused_store_members,
3315 unprovided_injects,
3316 unrendered_components,
3317 unused_component_props,
3318 unused_component_emits,
3319 unused_component_inputs,
3320 unused_component_outputs,
3321 unused_svelte_events,
3322 unused_server_actions,
3323 unused_load_data_keys,
3324 unused_catalog_entries,
3325 empty_catalog_groups,
3326 unresolved_catalog_references,
3327 unused_dependency_overrides,
3328 misconfigured_dependency_overrides,
3329 ..
3330 } = command
3331 else {
3332 unreachable!("check filter builder only handles check commands");
3333 };
3334
3335 let mut filters = base.clone();
3336 for (flag, active) in [
3337 ("--unused-store-members", *unused_store_members),
3338 ("--unprovided-injects", *unprovided_injects),
3339 ("--unrendered-components", *unrendered_components),
3340 ("--unused-component-props", *unused_component_props),
3341 ("--unused-component-emits", *unused_component_emits),
3342 ("--unused-component-inputs", *unused_component_inputs),
3343 ("--unused-component-outputs", *unused_component_outputs),
3344 ("--unused-svelte-events", *unused_svelte_events),
3345 ("--unused-server-actions", *unused_server_actions),
3346 ("--unused-load-data-keys", *unused_load_data_keys),
3347 ("--unused-catalog-entries", *unused_catalog_entries),
3348 ("--empty-catalog-groups", *empty_catalog_groups),
3349 (
3350 "--unresolved-catalog-references",
3351 *unresolved_catalog_references,
3352 ),
3353 (
3354 "--unused-dependency-overrides",
3355 *unused_dependency_overrides,
3356 ),
3357 (
3358 "--misconfigured-dependency-overrides",
3359 *misconfigured_dependency_overrides,
3360 ),
3361 ] {
3362 enable_check_filter(&mut filters, flag, active);
3363 }
3364 filters
3365}
3366
3367fn enable_check_filter(filters: &mut IssueFilters, flag: &str, active: bool) {
3368 if active {
3369 assert!(
3370 filters.enable_cli_filter_flag(flag),
3371 "check command uses unregistered dead-code filter flag {flag}"
3372 );
3373 }
3374}
3375
3376fn dispatch_inspect_command(
3377 dispatch: &DispatchContext<'_>,
3378 file: Option<String>,
3379 symbol: Option<String>,
3380 symbol_chain: bool,
3381 churn: bool,
3382) -> ExitCode {
3383 let target = match (file, symbol) {
3384 (Some(file), None) => inspect::InspectTarget::File { file },
3385 (None, Some(symbol)) => match symbol.rsplit_once(':') {
3386 Some((file, export_name))
3387 if !file.trim().is_empty() && !export_name.trim().is_empty() =>
3388 {
3389 inspect::InspectTarget::Symbol {
3390 file: file.to_string(),
3391 export_name: export_name.to_string(),
3392 }
3393 }
3394 _ => {
3395 return emit_error(
3396 "--symbol must be formatted as FILE:EXPORT",
3397 2,
3398 dispatch.output,
3399 );
3400 }
3401 },
3402 _ => {
3403 return emit_error(
3404 "inspect requires exactly one of --file or --symbol",
3405 2,
3406 dispatch.output,
3407 );
3408 }
3409 };
3410
3411 let churn_config = if churn {
3412 match load_config_for_analysis(
3413 dispatch.root,
3414 &dispatch.cli.config,
3415 ConfigLoadOptions {
3416 output: dispatch.output,
3417 no_cache: dispatch.cli.no_cache,
3418 threads: dispatch.threads,
3419 production_override: None,
3420 quiet: dispatch.quiet,
3421 allow_remote_extends: dispatch.cli.allow_remote_extends,
3422 },
3423 fallow_config::ProductionAnalysis::Health,
3424 ) {
3425 Ok(config) => Some(config),
3426 Err(code) => return code,
3427 }
3428 } else {
3429 None
3430 };
3431
3432 inspect::run_inspect(&inspect::InspectOptions {
3433 root: dispatch.root,
3434 config_path: dispatch.cli.config.as_ref(),
3435 output: dispatch.output,
3436 json_style: dispatch.json_style,
3437 no_cache: dispatch.cli.no_cache,
3438 no_production: dispatch.cli.no_production,
3439 max_file_size: dispatch.cli.max_file_size,
3440 threads: dispatch.threads,
3441 quiet: dispatch.quiet,
3442 production: dispatch.cli.production,
3443 workspace: dispatch.cli.workspace.as_ref(),
3444 target,
3445 churn_cache_dir: churn_config
3446 .as_ref()
3447 .map(|config| config.cache_dir.as_path()),
3448 symbol_chain,
3449 type_aware: dispatch.cli.type_aware_override(),
3450 type_aware_projects: &dispatch.cli.type_aware_project,
3451 type_aware_require: dispatch.cli.type_aware_require.map(Into::into),
3452 })
3453}
3454
3455fn dispatch_trace_command(
3456 dispatch: &DispatchContext<'_>,
3457 symbol: String,
3458 callers: bool,
3459 callees: bool,
3460 depth: Option<u32>,
3461) -> ExitCode {
3462 trace_chain::run_trace(&trace_chain::TraceChainOptions {
3463 root: dispatch.root,
3464 config_path: &dispatch.cli.config,
3465 output: dispatch.output,
3466 json_style: dispatch.json_style,
3467 no_cache: dispatch.cli.no_cache,
3468 threads: dispatch.threads,
3469 quiet: dispatch.quiet,
3470 allow_remote_extends: dispatch.cli.allow_remote_extends,
3471 target: symbol,
3472 callers,
3473 callees,
3474 depth: depth.unwrap_or(fallow_types::trace_chain::DEFAULT_TRACE_DEPTH),
3475 })
3476}
3477
3478fn dispatch_security_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3479 let Command::Security {
3480 subcommand,
3481 runtime_coverage,
3482 min_invocations_hot,
3483 file,
3484 gate,
3485 surface,
3486 } = command
3487 else {
3488 unreachable!("security dispatcher only handles security commands");
3489 };
3490
3491 let gate = gate.map(security::SecurityGateArg::into_mode);
3492 let cli = dispatch.cli;
3493 let (output, _quiet, fail_on_issues) =
3494 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
3495 let derived_flags = SecurityDerivedFlagState {
3496 output,
3497 json_style: dispatch.json_style,
3498 ci: cli.ci,
3499 fail_on_issues,
3500 sarif_file: cli.sarif_file.as_deref(),
3501 summary: cli.summary,
3502 explain: cli.explain,
3503 runtime_coverage: runtime_coverage.as_deref(),
3504 min_invocations_hot,
3505 file: file.as_slice(),
3506 gate,
3507 surface,
3508 };
3509 if let Some(code) = try_run_security_survivors(subcommand.as_ref(), &derived_flags) {
3510 return code;
3511 }
3512
3513 let scoped_files = scoped_security_files(&file, subcommand.as_ref());
3514 run_security_blind_spots_or_default(
3515 dispatch,
3516 &SecurityRunInputs {
3517 scoped_files: &scoped_files,
3518 subcommand: &subcommand,
3519 runtime_coverage: runtime_coverage.as_deref(),
3520 min_invocations_hot,
3521 gate,
3522 surface,
3523 },
3524 &derived_flags,
3525 )
3526}
3527
3528struct SecurityRunInputs<'a> {
3531 scoped_files: &'a [PathBuf],
3532 subcommand: &'a Option<SecuritySubcommand>,
3533 runtime_coverage: Option<&'a Path>,
3534 min_invocations_hot: u64,
3535 gate: Option<security::SecurityGateMode>,
3536 surface: bool,
3537}
3538
3539fn run_security_blind_spots_or_default(
3541 dispatch: &DispatchContext<'_>,
3542 inputs: &SecurityRunInputs<'_>,
3543 derived_flags: &SecurityDerivedFlagState<'_>,
3544) -> ExitCode {
3545 let cli = dispatch.cli;
3546 let (output, quiet, fail_on_issues) =
3547 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
3548 let opts = security::SecurityOptions {
3549 root: dispatch.root,
3550 config_path: &cli.config,
3551 output,
3552 json_style: dispatch.json_style,
3553 no_cache: cli.no_cache,
3554 threads: dispatch.threads,
3555 quiet,
3556 allow_remote_extends: cli.allow_remote_extends,
3557 fail_on_issues,
3558 sarif_file: cli.sarif_file.as_deref(),
3559 summary: cli.summary,
3560 changed_since: cli.changed_since.as_deref(),
3561 use_shared_diff_index: true,
3562 workspace: cli.workspace.as_deref(),
3563 changed_workspaces: cli.changed_workspaces.as_deref(),
3564 file: inputs.scoped_files,
3565 surface: inputs.surface,
3566 gate: inputs.gate,
3567 runtime_coverage: inputs.runtime_coverage,
3568 min_invocations_hot: inputs.min_invocations_hot,
3569 explain: cli.explain,
3570 };
3571 if matches!(
3572 inputs.subcommand,
3573 Some(SecuritySubcommand::BlindSpots { .. })
3574 ) {
3575 if let Some(code) = validate_security_blind_spots_flags(derived_flags) {
3576 return code;
3577 }
3578 security::run_blind_spots(&opts)
3579 } else {
3580 security::run(&opts)
3581 }
3582}
3583
3584fn try_run_security_survivors(
3587 subcommand: Option<&SecuritySubcommand>,
3588 flags: &SecurityDerivedFlagState<'_>,
3589) -> Option<ExitCode> {
3590 let Some(SecuritySubcommand::Survivors {
3591 candidates,
3592 verdicts,
3593 require_verdict_for_each_candidate,
3594 }) = subcommand
3595 else {
3596 return None;
3597 };
3598 if let Some(code) = validate_security_survivors_flags(flags) {
3599 return Some(code);
3600 }
3601 Some(security::run_survivors(
3602 &security::SecuritySurvivorsOptions {
3603 output: flags.output,
3604 json_style: flags.json_style,
3605 candidates,
3606 verdicts,
3607 require_verdict_for_each_candidate: *require_verdict_for_each_candidate,
3608 },
3609 ))
3610}
3611
3612fn scoped_security_files(
3614 file: &[PathBuf],
3615 subcommand: Option<&SecuritySubcommand>,
3616) -> Vec<PathBuf> {
3617 let mut scoped_files = file.to_vec();
3618 if let Some(SecuritySubcommand::BlindSpots {
3619 file: blind_spot_files,
3620 }) = subcommand
3621 {
3622 scoped_files.extend(blind_spot_files.iter().cloned());
3623 }
3624 scoped_files
3625}
3626
3627struct SecurityDerivedFlagState<'a> {
3628 output: fallow_config::OutputFormat,
3629 json_style: json_style::JsonStyle,
3630 ci: bool,
3631 fail_on_issues: bool,
3632 sarif_file: Option<&'a Path>,
3633 summary: bool,
3634 explain: bool,
3635 runtime_coverage: Option<&'a Path>,
3636 min_invocations_hot: u64,
3637 file: &'a [PathBuf],
3638 gate: Option<security::SecurityGateMode>,
3639 surface: bool,
3640}
3641
3642fn validate_security_survivors_flags(flags: &SecurityDerivedFlagState<'_>) -> Option<ExitCode> {
3643 let flag = if flags.ci {
3644 Some("--ci")
3645 } else if flags.fail_on_issues {
3646 Some("--fail-on-issues")
3647 } else if flags.sarif_file.is_some() {
3648 Some("--sarif-file")
3649 } else if flags.summary {
3650 Some("--summary")
3651 } else if flags.explain {
3652 Some("--explain")
3653 } else if flags.runtime_coverage.is_some() {
3654 Some("--runtime-coverage")
3655 } else if flags.min_invocations_hot != DEFAULT_MIN_INVOCATIONS_HOT {
3656 Some("--min-invocations-hot")
3657 } else if !flags.file.is_empty() {
3658 Some("--file")
3659 } else if flags.gate.is_some() {
3660 Some("--gate")
3661 } else if flags.surface {
3662 Some("--surface")
3663 } else {
3664 None
3665 }?;
3666 Some(emit_error(
3667 &format!("{flag} is not valid with `fallow security survivors`."),
3668 2,
3669 flags.output,
3670 ))
3671}
3672
3673fn validate_security_blind_spots_flags(flags: &SecurityDerivedFlagState<'_>) -> Option<ExitCode> {
3674 let flag = if flags.ci {
3675 Some("--ci")
3676 } else if flags.fail_on_issues {
3677 Some("--fail-on-issues")
3678 } else if flags.sarif_file.is_some() {
3679 Some("--sarif-file")
3680 } else if flags.summary {
3681 Some("--summary")
3682 } else if flags.explain {
3683 Some("--explain")
3684 } else if flags.runtime_coverage.is_some() {
3685 Some("--runtime-coverage")
3686 } else if flags.min_invocations_hot != DEFAULT_MIN_INVOCATIONS_HOT {
3687 Some("--min-invocations-hot")
3688 } else if flags.gate.is_some() {
3689 Some("--gate")
3690 } else if flags.surface {
3691 Some("--surface")
3692 } else {
3693 None
3694 }?;
3695 Some(emit_error(
3696 &format!("{flag} is not valid with `fallow security blind-spots`."),
3697 2,
3698 flags.output,
3699 ))
3700}
3701
3702fn dispatch_dupes_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3703 let Command::Dupes {
3704 mode,
3705 min_tokens,
3706 min_lines,
3707 min_occurrences,
3708 threshold,
3709 skip_local,
3710 cross_language,
3711 ignore_imports,
3712 no_ignore_imports,
3713 top,
3714 trace,
3715 } = command
3716 else {
3717 unreachable!("dupes dispatcher only handles dupes commands");
3718 };
3719
3720 dispatch_dupes(
3721 dispatch,
3722 &DupesDispatchArgs {
3723 mode,
3724 min_tokens,
3725 min_lines,
3726 min_occurrences,
3727 threshold,
3728 skip_local,
3729 cross_language,
3730 ignore_imports,
3731 no_ignore_imports,
3732 top,
3733 trace,
3734 },
3735 )
3736}
3737
3738fn dispatch_init_command(command: Command, root: &Path, quiet: bool) -> ExitCode {
3739 let Command::Init {
3740 toml,
3741 agents,
3742 hooks,
3743 branch,
3744 decline,
3745 } = command
3746 else {
3747 unreachable!("init dispatcher only handles init commands");
3748 };
3749
3750 init::run_init(&init::InitOptions {
3751 root,
3752 use_toml: toml,
3753 agents,
3754 hooks,
3755 branch: branch.as_deref(),
3756 decline,
3757 quiet,
3758 })
3759}
3760
3761fn dispatch_fix_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3762 let Command::Fix {
3763 dry_run,
3764 yes,
3765 no_create_config,
3766 } = command
3767 else {
3768 unreachable!("fix dispatcher only handles fix commands");
3769 };
3770
3771 dispatch_fix(
3772 dispatch,
3773 FixDispatchArgs {
3774 dry_run: *dry_run,
3775 yes: *yes,
3776 no_create_config: *no_create_config,
3777 },
3778 )
3779}
3780
3781fn dispatch_list_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3782 match command {
3783 Command::Workspaces => dispatch_list(dispatch, ListDispatchArgs::workspaces()),
3784 Command::List {
3785 entry_points,
3786 files,
3787 plugins,
3788 boundaries,
3789 workspaces,
3790 } => dispatch_list(
3791 dispatch,
3792 ListDispatchArgs {
3793 entry_points: *entry_points,
3794 files: *files,
3795 plugins: *plugins,
3796 boundaries: *boundaries,
3797 workspaces: *workspaces,
3798 },
3799 ),
3800 _ => unreachable!("list dispatcher only handles list commands"),
3801 }
3802}
3803
3804fn dispatch_migrate_command(command: Command, root: &Path) -> ExitCode {
3805 let Command::Migrate {
3806 toml,
3807 jsonc,
3808 dry_run,
3809 from,
3810 } = command
3811 else {
3812 unreachable!("migrate dispatcher only handles migrate commands");
3813 };
3814
3815 migrate::run_migrate(root, toml, jsonc, dry_run, from.as_deref())
3816}
3817
3818fn dispatch_license_command(
3819 subcommand: LicenseCli,
3820 output: fallow_config::OutputFormat,
3821 json_style: json_style::JsonStyle,
3822) -> ExitCode {
3823 license::run(&map_license_subcommand(subcommand), output, json_style)
3824}
3825
3826fn dispatch_ci_template_command(subcommand: CiTemplateCli) -> ExitCode {
3827 match subcommand {
3828 CiTemplateCli::Gitlab { vendor, force } => {
3829 ci_template::run_gitlab_template(&ci_template::GitlabTemplateOptions {
3830 vendor_dir: vendor,
3831 force,
3832 })
3833 }
3834 }
3835}
3836
3837fn dispatch_coverage_command(dispatch: &DispatchContext<'_>, subcommand: &CoverageCli) -> ExitCode {
3838 let cli = dispatch.cli;
3839 coverage::run(
3840 map_coverage_subcommand(subcommand, cli.explain),
3841 &coverage::RunContext {
3842 root: dispatch.root,
3843 config_path: &cli.config,
3844 output: dispatch.output,
3845 json_style: dispatch.json_style,
3846 quiet: dispatch.quiet,
3847 no_cache: cli.no_cache,
3848 threads: dispatch.threads,
3849 explain: cli.explain,
3850 allow_remote_extends: cli.allow_remote_extends,
3851 },
3852 )
3853}
3854
3855fn dispatch_health_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3856 let Command::Health {
3857 max_cyclomatic,
3858 max_cognitive,
3859 max_crap,
3860 top,
3861 sort,
3862 complexity,
3863 complexity_breakdown,
3864 file_scores,
3865 coverage_gaps,
3866 hotspots,
3867 ownership,
3868 ownership_emails,
3869 targets,
3870 type_coupling,
3871 css,
3872 effort,
3873 score,
3874 min_score,
3875 min_severity,
3876 report_only,
3877 since,
3878 min_commits,
3879 save_snapshot,
3880 trend,
3881 coverage,
3882 coverage_root,
3883 runtime_coverage,
3884 min_invocations_hot,
3885 min_observation_volume,
3886 low_traffic_threshold,
3887 } = command
3888 else {
3889 unreachable!("health dispatcher only handles health commands");
3890 };
3891
3892 let ownership = ownership || ownership_emails.is_some();
3893 let hotspots = hotspots || ownership;
3894 let args = HealthDispatchArgs {
3895 max_cyclomatic,
3896 max_cognitive,
3897 max_crap,
3898 top,
3899 sort,
3900 complexity,
3901 complexity_breakdown,
3902 file_scores,
3903 coverage_gaps,
3904 hotspots,
3905 ownership,
3906 ownership_emails: ownership_emails.map(EmailModeArg::to_config),
3907 targets,
3908 type_coupling,
3909 css,
3910 effort,
3911 score,
3912 min_score,
3913 min_severity: min_severity.map(HealthSeverityCli::to_health_severity),
3914 report_only,
3915 since: since.as_deref(),
3916 min_commits,
3917 save_snapshot: save_snapshot.as_ref(),
3918 trend,
3919 coverage: coverage.as_deref(),
3920 coverage_root: coverage_root.as_deref(),
3921 runtime_coverage: runtime_coverage.as_deref(),
3922 min_invocations_hot,
3923 min_observation_volume,
3924 low_traffic_threshold,
3925 };
3926 dispatch_health(dispatch, &args)
3927}
3928
3929fn dispatch_setup_hooks_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3930 let Command::SetupHooks {
3931 agent,
3932 dry_run,
3933 force,
3934 user,
3935 gitignore_claude,
3936 uninstall,
3937 } = command
3938 else {
3939 unreachable!("setup-hooks dispatcher only handles setup-hooks commands");
3940 };
3941
3942 setup_hooks::run_setup_hooks(&setup_hooks::SetupHooksOptions {
3943 root: dispatch.root,
3944 agent: *agent,
3945 dry_run: *dry_run,
3946 force: *force,
3947 user: *user,
3948 gitignore_claude: *gitignore_claude,
3949 uninstall: *uninstall,
3950 })
3951}
3952
3953fn dispatch_audit_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3954 let Command::Audit {
3955 production_dead_code,
3956 production_health,
3957 production_dupes,
3958 dead_code_baseline,
3959 health_baseline,
3960 dupes_baseline,
3961 max_crap,
3962 coverage,
3963 coverage_root,
3964 no_css,
3965 css_deep,
3966 no_css_deep,
3967 gate,
3968 runtime_coverage,
3969 min_invocations_hot,
3970 gate_marker,
3971 brief,
3972 max_decisions,
3973 walkthrough_guide,
3974 walkthrough_file,
3975 walkthrough,
3976 mark_viewed,
3977 show_cleared,
3978 show_deprioritized,
3979 } = command
3980 else {
3981 unreachable!("audit dispatcher only handles audit commands");
3982 };
3983
3984 let brief = brief || walkthrough_guide || walkthrough || walkthrough_file.is_some();
3987
3988 dispatch_audit(
3989 dispatch,
3990 &AuditDispatchArgs {
3991 production_dead_code,
3992 production_health,
3993 production_dupes,
3994 dead_code_baseline,
3995 health_baseline,
3996 dupes_baseline,
3997 max_crap,
3998 coverage,
3999 coverage_root,
4000 no_css,
4001 css_deep,
4002 no_css_deep,
4003 gate,
4004 runtime_coverage,
4005 min_invocations_hot,
4006 gate_marker,
4007 brief,
4008 max_decisions,
4009 walkthrough_guide,
4010 walkthrough_file,
4011 walkthrough,
4012 mark_viewed,
4013 show_cleared,
4014 show_deprioritized,
4015 },
4016 )
4017}
4018
4019fn dispatch_audit_cache_command(
4020 dispatch: &DispatchContext<'_>,
4021 subcommand: &AuditCacheCli,
4022) -> ExitCode {
4023 match subcommand {
4024 AuditCacheCli::Remove { dry_run, yes } => {
4025 if !*dry_run && !*yes && !std::io::stdin().is_terminal() {
4026 return emit_error(
4027 "audit-cache remove requires --yes (or --force) in non-interactive environments. Use --dry-run to preview removal first, then pass --yes to confirm.",
4028 2,
4029 dispatch.output,
4030 );
4031 }
4032 match base_worktree::remove_reusable_audit_caches(dispatch.root, *dry_run) {
4033 Ok(report) => {
4034 let action = if *dry_run { "would remove" } else { "removed" };
4035 if matches!(dispatch.output, fallow_config::OutputFormat::Json) {
4036 let value = serde_json::json!({
4037 "kind": "audit-cache-remove",
4038 "schema_version": 1,
4039 "command": "audit-cache remove",
4040 "root": dispatch.root,
4041 "dry_run": report.dry_run,
4042 "found": report.found,
4043 "would_remove": report.found.saturating_sub(report.skipped),
4044 "removed": report.removed,
4045 "skipped": report.skipped,
4046 "complete": report.skipped == 0,
4047 });
4048 let output_code = report::emit_report_json(
4049 &value,
4050 "audit cache removal",
4051 dispatch.json_style,
4052 );
4053 if output_code != ExitCode::SUCCESS {
4054 return output_code;
4055 }
4056 } else if !dispatch.quiet {
4057 println!(
4058 "audit cache: {action} {}, skipped {} for {}",
4059 if *dry_run {
4060 report.found.saturating_sub(report.skipped)
4061 } else {
4062 report.removed
4063 },
4064 report.skipped,
4065 dispatch.root.display(),
4066 );
4067 }
4068 if report.skipped == 0 {
4069 ExitCode::SUCCESS
4070 } else {
4071 ExitCode::from(2)
4072 }
4073 }
4074 Err(error) => emit_error(
4075 &format!(
4076 "failed to remove audit caches for {}: {error}",
4077 dispatch.root.display()
4078 ),
4079 2,
4080 dispatch.output,
4081 ),
4082 }
4083 }
4084 }
4085}
4086
4087fn dispatch_flags_command(dispatch: &DispatchContext<'_>, top: Option<usize>) -> ExitCode {
4088 let cli = dispatch.cli;
4089 let root = dispatch.root;
4090 let output = dispatch.output;
4091 let quiet = dispatch.quiet;
4092 let threads = dispatch.threads;
4093 let production = match resolve_production_modes(cli, root, output, false, false, false) {
4094 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::DeadCode),
4095 Err(code) => return code,
4096 };
4097 flags::run_flags(&flags::FlagsOptions {
4098 root,
4099 config_path: &cli.config,
4100 output,
4101 json_style: dispatch.json_style,
4102 no_cache: cli.no_cache,
4103 threads,
4104 quiet,
4105 allow_remote_extends: cli.allow_remote_extends,
4106 production,
4107 workspace: cli.workspace.as_deref(),
4108 changed_workspaces: cli.changed_workspaces.as_deref(),
4109 changed_since: cli.changed_since.as_deref(),
4110 explain: cli.explain,
4111 top,
4112 })
4113}
4114
4115fn dispatch_suppressions_command(
4116 dispatch: &DispatchContext<'_>,
4117 file: &[std::path::PathBuf],
4118) -> ExitCode {
4119 let cli = dispatch.cli;
4120 let root = dispatch.root;
4121 let output = dispatch.output;
4122 let production = match resolve_production_modes(cli, root, output, false, false, false) {
4123 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::DeadCode),
4124 Err(code) => return code,
4125 };
4126 suppressions::run_suppressions(&suppressions::SuppressionsOptions {
4127 root,
4128 config_path: &cli.config,
4129 output,
4130 json_style: dispatch.json_style,
4131 no_cache: cli.no_cache,
4132 threads: dispatch.threads,
4133 quiet: dispatch.quiet,
4134 allow_remote_extends: cli.allow_remote_extends,
4135 production,
4136 workspace: cli.workspace.as_deref(),
4137 changed_workspaces: cli.changed_workspaces.as_deref(),
4138 changed_since: cli.changed_since.as_deref(),
4139 file,
4140 })
4141}
4142
4143fn dispatch_guard_command(dispatch: &DispatchContext<'_>, files: &[String]) -> ExitCode {
4144 guard::run_guard(&guard::GuardOptions {
4145 root: dispatch.root,
4146 config_path: &dispatch.cli.config,
4147 output: dispatch.output,
4148 json_style: dispatch.json_style,
4149 quiet: dispatch.quiet,
4150 allow_remote_extends: dispatch.cli.allow_remote_extends,
4151 files,
4152 })
4153}
4154
4155fn dispatch_rule_pack_command(dispatch: &DispatchContext<'_>, subcommand: RulePackCli) -> ExitCode {
4156 let ctx = rule_pack::RulePackContext {
4157 root: dispatch.root,
4158 config_path: &dispatch.cli.config,
4159 output: dispatch.output,
4160 json_style: dispatch.json_style,
4161 quiet: dispatch.quiet,
4162 no_cache: dispatch.cli.no_cache,
4163 threads: Some(dispatch.threads),
4164 allow_remote_extends: dispatch.cli.allow_remote_extends,
4165 };
4166 rule_pack::run(&map_rule_pack_subcommand(subcommand), &ctx)
4167}
4168
4169fn map_rule_pack_subcommand(subcommand: RulePackCli) -> rule_pack::RulePackSubcommand {
4170 match subcommand {
4171 RulePackCli::Init {
4172 name,
4173 template,
4174 dir,
4175 no_config,
4176 } => rule_pack::RulePackSubcommand::Init(rule_pack::InitArgs {
4177 name,
4178 template,
4179 dir,
4180 no_config,
4181 }),
4182 RulePackCli::List => rule_pack::RulePackSubcommand::List,
4183 RulePackCli::Test { pack } => {
4184 rule_pack::RulePackSubcommand::Test(rule_pack::TestArgs { pack })
4185 }
4186 RulePackCli::Schema => rule_pack::RulePackSubcommand::Schema,
4187 }
4188}
4189
4190fn map_license_subcommand(sub: LicenseCli) -> license::LicenseSubcommand {
4191 match sub {
4192 LicenseCli::Activate {
4193 jwt,
4194 from_file,
4195 stdin,
4196 trial,
4197 email,
4198 } => license::LicenseSubcommand::Activate(license::ActivateArgs {
4199 raw_jwt: jwt,
4200 from_file,
4201 from_stdin: stdin,
4202 trial,
4203 email,
4204 }),
4205 LicenseCli::Status => license::LicenseSubcommand::Status,
4206 LicenseCli::Refresh => license::LicenseSubcommand::Refresh,
4207 LicenseCli::Deactivate => license::LicenseSubcommand::Deactivate,
4208 }
4209}
4210
4211fn map_telemetry_subcommand(sub: TelemetryCli) -> telemetry::TelemetryCommand {
4212 match sub {
4213 TelemetryCli::Status => telemetry::TelemetryCommand::Status,
4214 TelemetryCli::Enable => telemetry::TelemetryCommand::Enable,
4215 TelemetryCli::Disable => telemetry::TelemetryCommand::Disable,
4216 TelemetryCli::Inspect { example } => telemetry::TelemetryCommand::Inspect { example },
4217 }
4218}
4219
4220fn map_ci_subcommand(sub: CiCli) -> ci::CiCommand {
4221 match sub {
4222 command @ CiCli::PlanPrComment { .. } => map_ci_plan_pr_comment(command),
4223 command @ CiCli::PostPrComment { .. } => map_ci_post_pr_comment(command),
4224 command @ CiCli::PostReview { .. } => map_ci_post_review(command),
4225 command @ CiCli::PostCheckRun { .. } => map_ci_post_check_run(command),
4226 command @ CiCli::ReconcileReview { .. } => map_ci_reconcile_review(command),
4227 }
4228}
4229
4230fn map_ci_plan_pr_comment(command: CiCli) -> ci::CiCommand {
4231 let CiCli::PlanPrComment {
4232 body,
4233 marker_id,
4234 clean,
4235 existing_comment_id,
4236 existing_body,
4237 } = command
4238 else {
4239 unreachable!("ci plan-pr-comment mapper called with different variant");
4240 };
4241
4242 ci::CiCommand::PlanPrComment {
4243 body,
4244 marker_id,
4245 clean,
4246 existing_comment_id,
4247 existing_body,
4248 }
4249}
4250
4251fn map_ci_post_pr_comment(command: CiCli) -> ci::CiCommand {
4252 let CiCli::PostPrComment {
4253 provider,
4254 pr,
4255 mr,
4256 body,
4257 envelope,
4258 marker_id,
4259 clean,
4260 repo,
4261 project_id,
4262 api_url,
4263 dry_run,
4264 } = command
4265 else {
4266 unreachable!("ci post-pr-comment mapper called with different variant");
4267 };
4268
4269 ci::CiCommand::PostPrComment {
4270 provider: map_ci_provider(provider),
4271 target: pr.or(mr),
4272 body,
4273 envelope,
4274 marker_id,
4275 clean,
4276 repo,
4277 project_id,
4278 api_url,
4279 dry_run,
4280 }
4281}
4282
4283fn map_ci_post_review(command: CiCli) -> ci::CiCommand {
4284 let CiCli::PostReview {
4285 provider,
4286 pr,
4287 mr,
4288 envelope,
4289 repo,
4290 project_id,
4291 api_url,
4292 dry_run,
4293 } = command
4294 else {
4295 unreachable!("ci post-review mapper called with different variant");
4296 };
4297
4298 ci::CiCommand::PostReview {
4299 provider: map_ci_provider(provider),
4300 target: pr.or(mr),
4301 envelope,
4302 repo,
4303 project_id,
4304 api_url,
4305 dry_run,
4306 }
4307}
4308
4309fn map_ci_post_check_run(command: CiCli) -> ci::CiCommand {
4310 let CiCli::PostCheckRun {
4311 provider,
4312 decision,
4313 repo,
4314 head_sha,
4315 api_url,
4316 split_gates,
4317 dry_run,
4318 } = command
4319 else {
4320 unreachable!("ci post-check-run mapper called with different variant");
4321 };
4322
4323 ci::CiCommand::PostCheckRun {
4324 provider: map_ci_provider(provider),
4325 decision,
4326 repo,
4327 head_sha,
4328 api_url,
4329 split_gates,
4330 dry_run,
4331 }
4332}
4333
4334fn map_ci_reconcile_review(command: CiCli) -> ci::CiCommand {
4335 let CiCli::ReconcileReview {
4336 provider,
4337 pr,
4338 mr,
4339 envelope,
4340 repo,
4341 project_id,
4342 api_url,
4343 dry_run,
4344 } = command
4345 else {
4346 unreachable!("ci reconcile-review mapper called with different variant");
4347 };
4348
4349 ci::CiCommand::ReconcileReview {
4350 provider: map_ci_provider(provider),
4351 target: pr.or(mr),
4352 envelope,
4353 repo,
4354 project_id,
4355 api_url,
4356 dry_run,
4357 }
4358}
4359
4360fn map_ci_provider(provider: CiProviderArg) -> ci::CiProvider {
4361 match provider {
4362 CiProviderArg::Github => ci::CiProvider::Github,
4363 CiProviderArg::Gitlab => ci::CiProvider::Gitlab,
4364 }
4365}
4366
4367fn map_coverage_subcommand(sub: &CoverageCli, explain: bool) -> coverage::CoverageSubcommand {
4368 match sub {
4369 CoverageCli::Setup {
4370 yes,
4371 non_interactive,
4372 json,
4373 } => map_coverage_setup(*yes, *non_interactive, *json, explain),
4374 CoverageCli::Analyze { .. } => map_coverage_analyze(sub),
4375 CoverageCli::UploadInventory { .. } => map_coverage_upload_inventory(sub),
4376 CoverageCli::UploadSourceMaps { .. } => map_coverage_upload_source_maps(sub),
4377 CoverageCli::UploadStaticFindings { .. } => map_coverage_upload_static_findings(sub),
4378 }
4379}
4380
4381fn map_coverage_setup(
4382 yes: bool,
4383 non_interactive: bool,
4384 json: bool,
4385 explain: bool,
4386) -> coverage::CoverageSubcommand {
4387 coverage::CoverageSubcommand::Setup(coverage::SetupArgs {
4388 yes,
4389 non_interactive: non_interactive || json,
4390 json,
4391 explain,
4392 })
4393}
4394
4395fn map_coverage_analyze(sub: &CoverageCli) -> coverage::CoverageSubcommand {
4396 let CoverageCli::Analyze {
4397 runtime_coverage,
4398 cloud,
4399 api_key,
4400 api_endpoint,
4401 repo,
4402 project_id,
4403 coverage_period,
4404 environment,
4405 commit_sha,
4406 production,
4407 min_invocations_hot,
4408 min_observation_volume,
4409 low_traffic_threshold,
4410 top,
4411 blast_radius,
4412 importance,
4413 } = sub
4414 else {
4415 unreachable!("coverage analyze mapper called with non-analyze variant");
4416 };
4417 coverage::CoverageSubcommand::Analyze(coverage::AnalyzeArgs {
4418 runtime_coverage: runtime_coverage.clone(),
4419 cloud: *cloud,
4420 api_key: api_key.clone(),
4421 api_endpoint: api_endpoint.clone(),
4422 repo: repo.clone(),
4423 project_id: project_id.clone(),
4424 coverage_period: *coverage_period,
4425 environment: environment.clone(),
4426 commit_sha: commit_sha.clone(),
4427 production: *production,
4428 min_invocations_hot: *min_invocations_hot,
4429 min_observation_volume: *min_observation_volume,
4430 low_traffic_threshold: *low_traffic_threshold,
4431 top: *top,
4432 blast_radius: *blast_radius,
4433 importance: *importance,
4434 })
4435}
4436
4437fn map_coverage_upload_inventory(sub: &CoverageCli) -> coverage::CoverageSubcommand {
4438 let CoverageCli::UploadInventory {
4439 api_key,
4440 api_endpoint,
4441 project_id,
4442 git_sha,
4443 allow_dirty,
4444 exclude_paths,
4445 path_prefix,
4446 dry_run,
4447 with_callers,
4448 ignore_upload_errors,
4449 } = sub
4450 else {
4451 unreachable!("coverage inventory mapper called with non-inventory variant");
4452 };
4453 coverage::CoverageSubcommand::UploadInventory(coverage::UploadInventoryArgs {
4454 api_key: api_key.clone(),
4455 api_endpoint: api_endpoint.clone(),
4456 project_id: project_id.clone(),
4457 git_sha: git_sha.clone(),
4458 allow_dirty: *allow_dirty,
4459 exclude_paths: exclude_paths.clone(),
4460 path_prefix: path_prefix.clone(),
4461 dry_run: *dry_run,
4462 with_callers: *with_callers,
4463 ignore_upload_errors: *ignore_upload_errors,
4464 })
4465}
4466
4467fn map_coverage_upload_source_maps(sub: &CoverageCli) -> coverage::CoverageSubcommand {
4468 let CoverageCli::UploadSourceMaps {
4469 dir,
4470 include,
4471 exclude,
4472 repo,
4473 git_sha,
4474 endpoint,
4475 strip_path,
4476 dry_run,
4477 concurrency,
4478 fail_fast,
4479 } = sub
4480 else {
4481 unreachable!("coverage source-map mapper called with non-source-map variant");
4482 };
4483 coverage::CoverageSubcommand::UploadSourceMaps(coverage::UploadSourceMapsArgs {
4484 dir: dir.clone(),
4485 include: include.clone(),
4486 exclude: exclude.clone(),
4487 repo: repo.clone(),
4488 git_sha: git_sha.clone(),
4489 endpoint: endpoint.clone(),
4490 strip_path: *strip_path,
4491 dry_run: *dry_run,
4492 concurrency: *concurrency,
4493 fail_fast: *fail_fast,
4494 })
4495}
4496
4497fn map_coverage_upload_static_findings(sub: &CoverageCli) -> coverage::CoverageSubcommand {
4498 let CoverageCli::UploadStaticFindings {
4499 api_key,
4500 api_endpoint,
4501 project_id,
4502 git_sha,
4503 allow_dirty,
4504 dry_run,
4505 ignore_upload_errors,
4506 } = sub
4507 else {
4508 unreachable!("coverage static-findings mapper called with non-static variant");
4509 };
4510 coverage::CoverageSubcommand::UploadStaticFindings(coverage::UploadStaticFindingsArgs {
4511 api_key: api_key.clone(),
4512 api_endpoint: api_endpoint.clone(),
4513 project_id: project_id.clone(),
4514 git_sha: git_sha.clone(),
4515 allow_dirty: *allow_dirty,
4516 dry_run: *dry_run,
4517 ignore_upload_errors: *ignore_upload_errors,
4518 })
4519}
4520
4521struct CheckDispatchArgs {
4522 filters: IssueFilters,
4523 trace_opts: TraceOptions,
4524 include_dupes: bool,
4525 type_aware: Option<bool>,
4526 type_aware_project: Vec<std::path::PathBuf>,
4527 type_aware_require: Option<TypeAwareRequireArg>,
4528 top: Option<usize>,
4529 file: Vec<std::path::PathBuf>,
4530}
4531
4532#[derive(Clone, Copy)]
4533struct ListDispatchArgs {
4534 entry_points: bool,
4535 files: bool,
4536 plugins: bool,
4537 boundaries: bool,
4538 workspaces: bool,
4539}
4540
4541impl ListDispatchArgs {
4542 fn workspaces() -> Self {
4543 Self {
4544 entry_points: false,
4545 files: false,
4546 plugins: false,
4547 boundaries: false,
4548 workspaces: true,
4549 }
4550 }
4551}
4552
4553fn dispatch_viz(
4554 dispatch: &DispatchContext<'_>,
4555 output_path: Option<&std::path::Path>,
4556 no_open: bool,
4557 format: viz::VizFormat,
4558) -> ExitCode {
4559 let cli = dispatch.cli;
4560 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
4561 Ok(production) => production,
4562 Err(code) => return code,
4563 };
4564 viz::run_viz(&viz::VizOptions {
4565 root: dispatch.root,
4566 config_path: &cli.config,
4567 no_cache: cli.no_cache,
4568 threads: dispatch.threads,
4569 quiet: dispatch.quiet,
4570 production,
4571 allow_remote_extends: cli.allow_remote_extends,
4572 output_path,
4573 no_open,
4574 format,
4575 })
4576}
4577
4578fn dispatch_watch(dispatch: &DispatchContext<'_>, no_clear: bool) -> ExitCode {
4579 let cli = dispatch.cli;
4580 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
4581 Ok(production) => production,
4582 Err(code) => return code,
4583 };
4584 watch::run_watch(&watch::WatchOptions {
4585 root: dispatch.root,
4586 config_path: &cli.config,
4587 output: dispatch.output,
4588 json_style: dispatch.json_style,
4589 no_cache: cli.no_cache,
4590 threads: dispatch.threads,
4591 quiet: dispatch.quiet,
4592 allow_remote_extends: cli.allow_remote_extends,
4593 production,
4594 clear_screen: !no_clear,
4595 explain: cli.explain,
4596 include_entry_exports: cli.include_entry_exports,
4597 type_aware: cli.type_aware_override(),
4598 type_aware_projects: &cli.type_aware_project,
4599 type_aware_require: cli.type_aware_require.map(Into::into),
4600 })
4601}
4602
4603#[derive(Clone, Copy)]
4604struct FixDispatchArgs {
4605 dry_run: bool,
4606 yes: bool,
4607 no_create_config: bool,
4608}
4609
4610fn dispatch_fix(dispatch: &DispatchContext<'_>, args: FixDispatchArgs) -> ExitCode {
4611 let cli = dispatch.cli;
4612 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
4613 Ok(production) => production,
4614 Err(code) => return code,
4615 };
4616 fix::run_fix(&fix::FixOptions {
4617 root: dispatch.root,
4618 config_path: &cli.config,
4619 output: dispatch.output,
4620 json_style: dispatch.json_style,
4621 no_cache: cli.no_cache,
4622 threads: dispatch.threads,
4623 quiet: dispatch.quiet,
4624 allow_remote_extends: cli.allow_remote_extends,
4625 dry_run: args.dry_run,
4626 yes: args.yes,
4627 production,
4628 no_create_config: args.no_create_config,
4629 type_aware: cli.type_aware_override(),
4630 type_aware_projects: &cli.type_aware_project,
4631 type_aware_require: cli.type_aware_require.map(Into::into),
4632 })
4633}
4634
4635fn dispatch_list(dispatch: &DispatchContext<'_>, args: ListDispatchArgs) -> ExitCode {
4636 let cli = dispatch.cli;
4637 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
4638 Ok(production) => production,
4639 Err(code) => return code,
4640 };
4641 list::run_list(&ListOptions {
4642 root: dispatch.root,
4643 config_path: &cli.config,
4644 output: dispatch.output,
4645 json_style: dispatch.json_style,
4646 threads: dispatch.threads,
4647 no_cache: cli.no_cache,
4648 entry_points: args.entry_points,
4649 files: args.files,
4650 plugins: args.plugins,
4651 boundaries: args.boundaries,
4652 workspaces: args.workspaces,
4653 production,
4654 allow_remote_extends: cli.allow_remote_extends,
4655 })
4656}
4657
4658fn dispatch_check(dispatch: &DispatchContext<'_>, args: &CheckDispatchArgs) -> ExitCode {
4659 let cli = dispatch.cli;
4660 let (output, quiet, fail_on_issues) =
4661 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
4662 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
4663 Ok(production) => production,
4664 Err(code) => return code,
4665 };
4666 if let Some(code) = validate_type_aware_check_options(dispatch, args) {
4667 return code;
4668 }
4669 check::run_check(&CheckOptions {
4670 root: dispatch.root,
4671 config_path: &cli.config,
4672 output,
4673 json_style: dispatch.json_style,
4674 no_cache: cli.no_cache,
4675 threads: dispatch.threads,
4676 quiet,
4677 allow_remote_extends: cli.allow_remote_extends,
4678 fail_on_issues,
4679 filters: &args.filters,
4680 changed_since: cli.changed_since.as_deref(),
4681 diff_index: None,
4682 use_shared_diff_index: true,
4683 baseline: cli.baseline.as_deref(),
4684 save_baseline: cli.save_baseline.as_deref(),
4685 sarif_file: cli.sarif_file.as_deref(),
4686 production,
4687 production_override: Some(production),
4688 workspace: cli.workspace.as_deref(),
4689 changed_workspaces: cli.changed_workspaces.as_deref(),
4690 group_by: cli.group_by,
4691 include_dupes: args.include_dupes,
4692 type_aware: args.type_aware,
4693 type_aware_config_override: None,
4694 type_aware_projects: &args.type_aware_project,
4695 type_aware_require: args.type_aware_require.map(Into::into),
4696 trace_opts: &args.trace_opts,
4697 explain: cli.explain,
4698 top: args.top,
4699 file: &args.file,
4700 include_entry_exports: cli.include_entry_exports,
4701 summary: cli.summary,
4702 regression_opts: dispatch.regression_opts(
4703 cli.changed_since.is_some()
4704 || cli.workspace.is_some()
4705 || cli.changed_workspaces.is_some()
4706 || !args.file.is_empty(),
4707 ),
4708 retain_modules_for_health: false,
4709 defer_performance: false,
4710 analysis_snapshot: fallow_config::AnalysisSnapshot::Current,
4711 })
4712}
4713
4714fn validate_type_aware_check_options(
4715 dispatch: &DispatchContext<'_>,
4716 args: &CheckDispatchArgs,
4717) -> Option<ExitCode> {
4718 let output = dispatch.output;
4719 if !args.type_aware_project.is_empty() && args.type_aware != Some(true) {
4720 return Some(emit_error(
4721 "--type-aware-project requires --type-aware",
4722 2,
4723 output,
4724 ));
4725 }
4726 if args.type_aware_require.is_some() && args.type_aware != Some(true) {
4727 return Some(emit_error(
4728 "--type-aware-require requires --type-aware",
4729 2,
4730 output,
4731 ));
4732 }
4733 if args.trace_opts.symbol_impact.is_some() && args.type_aware != Some(true) {
4734 return Some(emit_error(
4735 "--symbol-impact requires --type-aware",
4736 2,
4737 output,
4738 ));
4739 }
4740 let focused_output = args.trace_opts.trace_export.is_some()
4741 || args.trace_opts.trace_file.is_some()
4742 || args.trace_opts.trace_dependency.is_some()
4743 || args.trace_opts.impact_closure.is_some()
4744 || args.trace_opts.symbol_impact.is_some();
4745 if focused_output
4746 && !matches!(
4747 output,
4748 fallow_config::OutputFormat::Human | fallow_config::OutputFormat::Json
4749 )
4750 {
4751 return Some(emit_error(
4752 "focused trace and impact queries support human and JSON output",
4753 2,
4754 output,
4755 ));
4756 }
4757 if args.type_aware == Some(true)
4758 && !matches!(
4759 output,
4760 fallow_config::OutputFormat::Human
4761 | fallow_config::OutputFormat::Json
4762 | fallow_config::OutputFormat::Sarif
4763 | fallow_config::OutputFormat::Compact
4764 | fallow_config::OutputFormat::Markdown
4765 | fallow_config::OutputFormat::CodeClimate
4766 )
4767 {
4768 return Some(emit_error(
4769 "--type-aware supports human, JSON, SARIF, compact, markdown, and CodeClimate output; pair CodeClimate with the JSON artifact to preserve semantic provenance",
4770 2,
4771 output,
4772 ));
4773 }
4774 None
4775}
4776
4777fn resolve_ignore_imports(ignore_imports: bool, no_ignore_imports: bool) -> Option<bool> {
4783 if no_ignore_imports {
4784 Some(false)
4785 } else if ignore_imports {
4786 Some(true)
4787 } else {
4788 None
4789 }
4790}
4791
4792struct DupesDispatchArgs {
4793 mode: Option<DupesMode>,
4794 min_tokens: Option<usize>,
4795 min_lines: Option<usize>,
4796 min_occurrences: Option<usize>,
4797 threshold: Option<f64>,
4798 skip_local: bool,
4799 cross_language: bool,
4800 ignore_imports: bool,
4801 no_ignore_imports: bool,
4802 top: Option<usize>,
4803 trace: Option<String>,
4804}
4805
4806fn dispatch_dupes(dispatch: &DispatchContext<'_>, args: &DupesDispatchArgs) -> ExitCode {
4807 let cli = dispatch.cli;
4808 let (output, quiet, _fail_on_issues) =
4809 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
4810 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::Dupes) {
4811 Ok(production) => production,
4812 Err(code) => return code,
4813 };
4814 dupes::run_dupes(&DupesOptions {
4815 root: dispatch.root,
4816 config_path: &cli.config,
4817 output,
4818 json_style: dispatch.json_style,
4819 no_cache: cli.no_cache,
4820 threads: dispatch.threads,
4821 quiet,
4822 allow_remote_extends: cli.allow_remote_extends,
4823 mode: args.mode,
4824 min_tokens: args.min_tokens,
4825 min_lines: args.min_lines,
4826 min_occurrences: args.min_occurrences,
4827 threshold: args.threshold,
4828 skip_local: args.skip_local,
4829 cross_language: args.cross_language,
4830 ignore_imports: resolve_ignore_imports(args.ignore_imports, args.no_ignore_imports),
4831 top: args.top,
4832 baseline_path: cli.baseline.as_deref(),
4833 save_baseline_path: cli.save_baseline.as_deref(),
4834 production,
4835 production_override: Some(production),
4836 trace: args.trace.as_deref(),
4837 changed_since: cli.changed_since.as_deref(),
4838 diff_index: None,
4839 use_shared_diff_index: true,
4840 changed_files: None,
4841 workspace: cli.workspace.as_deref(),
4842 changed_workspaces: cli.changed_workspaces.as_deref(),
4843 explain: cli.explain,
4844 explain_skipped: cli.explain_skipped,
4845 summary: cli.summary,
4846 group_by: cli.group_by,
4847 performance: cli.performance,
4848 })
4849}
4850
4851struct AuditDispatchArgs {
4852 production_dead_code: bool,
4853 production_health: bool,
4854 production_dupes: bool,
4855 dead_code_baseline: Option<PathBuf>,
4856 health_baseline: Option<PathBuf>,
4857 dupes_baseline: Option<PathBuf>,
4858 max_crap: Option<f64>,
4859 coverage: Option<PathBuf>,
4860 coverage_root: Option<PathBuf>,
4861 no_css: bool,
4862 css_deep: bool,
4863 no_css_deep: bool,
4864 gate: Option<AuditGateArg>,
4865 runtime_coverage: Option<PathBuf>,
4866 min_invocations_hot: u64,
4867 gate_marker: Option<String>,
4868 brief: bool,
4869 max_decisions: usize,
4870 walkthrough_guide: bool,
4872 walkthrough_file: Option<PathBuf>,
4875 walkthrough: bool,
4877 mark_viewed: Vec<PathBuf>,
4879 show_cleared: bool,
4881 show_deprioritized: bool,
4883}
4884
4885struct ResolvedAuditInputs {
4886 audit_cfg: fallow_config::AuditConfig,
4887 cache_dir: PathBuf,
4888 production: ProductionModes,
4889 dead_code_baseline: Option<PathBuf>,
4890 health_baseline: Option<PathBuf>,
4891 dupes_baseline: Option<PathBuf>,
4892 coverage: Option<PathBuf>,
4893}
4894
4895fn dispatch_audit(dispatch: &DispatchContext<'_>, args: &AuditDispatchArgs) -> ExitCode {
4896 let cli = dispatch.cli;
4897 let output = dispatch.output;
4898
4899 if cli.baseline.is_some() || cli.save_baseline.is_some() {
4900 return emit_error(
4901 "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>`)",
4902 2,
4903 output,
4904 );
4905 }
4906
4907 let inputs = match resolve_audit_inputs(dispatch, args) {
4908 Ok(inputs) => inputs,
4909 Err(code) => return code,
4910 };
4911
4912 run_resolved_audit(dispatch, args, &inputs)
4913}
4914
4915fn resolve_audit_inputs(
4916 dispatch: &DispatchContext<'_>,
4917 args: &AuditDispatchArgs,
4918) -> Result<ResolvedAuditInputs, ExitCode> {
4919 let cli = dispatch.cli;
4920 let root = dispatch.root;
4921 let output = dispatch.output;
4922 let config = load_config(
4923 root,
4924 &cli.config,
4925 LoadConfigArgs {
4926 output,
4927 no_cache: cli.no_cache,
4928 threads: dispatch.threads,
4929 production: cli.production,
4930 quiet: dispatch.quiet,
4931 allow_remote_extends: cli.allow_remote_extends,
4932 },
4933 )?;
4934 let cache_dir = config.cache_dir.clone();
4935 let audit_cfg = config.audit;
4936 let production = resolve_production_modes(
4937 cli,
4938 root,
4939 output,
4940 args.production_dead_code,
4941 args.production_health,
4942 args.production_dupes,
4943 )?;
4944 let resolved_dead_code_baseline = resolve_audit_baseline_path(
4945 root,
4946 args.dead_code_baseline.as_deref(),
4947 audit_cfg.dead_code_baseline.as_deref(),
4948 );
4949 let resolved_health_baseline = resolve_audit_baseline_path(
4950 root,
4951 args.health_baseline.as_deref(),
4952 audit_cfg.health_baseline.as_deref(),
4953 );
4954 let resolved_dupes_baseline = resolve_audit_baseline_path(
4955 root,
4956 args.dupes_baseline.as_deref(),
4957 audit_cfg.dupes_baseline.as_deref(),
4958 );
4959 let coverage = args
4960 .coverage
4961 .clone()
4962 .or_else(|| std::env::var("FALLOW_COVERAGE").ok().map(PathBuf::from));
4963
4964 Ok(ResolvedAuditInputs {
4965 audit_cfg,
4966 cache_dir,
4967 production,
4968 dead_code_baseline: resolved_dead_code_baseline,
4969 health_baseline: resolved_health_baseline,
4970 dupes_baseline: resolved_dupes_baseline,
4971 coverage,
4972 })
4973}
4974
4975fn audit_css_enabled(config: &fallow_config::AuditConfig, args: &AuditDispatchArgs) -> bool {
4976 !args.no_css && config.css.unwrap_or(true)
4977}
4978
4979fn audit_css_deep_enabled(config: &fallow_config::AuditConfig, args: &AuditDispatchArgs) -> bool {
4980 audit_css_enabled(config, args)
4981 && !args.no_css_deep
4982 && (args.css_deep || config.css_deep.unwrap_or(true))
4983}
4984
4985fn run_resolved_audit(
4986 dispatch: &DispatchContext<'_>,
4987 args: &AuditDispatchArgs,
4988 inputs: &ResolvedAuditInputs,
4989) -> ExitCode {
4990 let cli = dispatch.cli;
4991 audit::run_audit_with_type_aware(
4992 &audit::AuditOptions {
4993 root: dispatch.root,
4994 config_path: &cli.config,
4995 cache_dir: &inputs.cache_dir,
4996 output: dispatch.output,
4997 json_style: dispatch.json_style,
4998 no_cache: cli.no_cache,
4999 threads: dispatch.threads,
5000 quiet: dispatch.quiet,
5001 allow_remote_extends: cli.allow_remote_extends,
5002 changed_since: cli.changed_since.as_deref(),
5003 production: cli.production,
5004 production_dead_code: Some(inputs.production.dead_code),
5005 production_health: Some(inputs.production.health),
5006 production_dupes: Some(inputs.production.dupes),
5007 workspace: cli.workspace.as_deref(),
5008 changed_workspaces: cli.changed_workspaces.as_deref(),
5009 explain: cli.explain,
5010 explain_skipped: cli.explain_skipped,
5011 performance: cli.performance,
5012 group_by: cli.group_by,
5013 dead_code_baseline: inputs.dead_code_baseline.as_deref(),
5014 health_baseline: inputs.health_baseline.as_deref(),
5015 dupes_baseline: inputs.dupes_baseline.as_deref(),
5016 health_baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
5017 max_crap: args.max_crap,
5018 coverage: inputs.coverage.as_deref(),
5019 coverage_root: args.coverage_root.as_deref(),
5020 gate: args.gate.map_or(inputs.audit_cfg.gate, Into::into),
5021 include_entry_exports: cli.include_entry_exports,
5022 css: audit_css_enabled(&inputs.audit_cfg, args),
5026 css_deep: audit_css_deep_enabled(&inputs.audit_cfg, args),
5027 runtime_coverage: args.runtime_coverage.as_deref(),
5028 min_invocations_hot: args.min_invocations_hot,
5029 brief: args.brief,
5030 max_decisions: args.max_decisions,
5031 walkthrough_guide: args.walkthrough_guide,
5032 walkthrough: args.walkthrough,
5033 mark_viewed: &args.mark_viewed,
5034 show_cleared: args.show_cleared,
5035 walkthrough_file: args.walkthrough_file.as_deref(),
5036 show_deprioritized: args.show_deprioritized,
5037 },
5038 args.gate_marker.as_deref(),
5039 audit::AuditTypeAwareOptions {
5040 enabled: cli.type_aware_override(),
5041 config_default: inputs.audit_cfg.type_aware,
5042 projects: &cli.type_aware_project,
5043 require: cli.type_aware_require.map(Into::into),
5044 },
5045 )
5046}
5047
5048fn dispatch_decision_surface(dispatch: &DispatchContext<'_>, max_decisions: usize) -> ExitCode {
5052 let args = decision_surface_audit_args(max_decisions);
5053 let inputs = match resolve_audit_inputs(dispatch, &args) {
5054 Ok(inputs) => inputs,
5055 Err(code) => return code,
5056 };
5057 audit::run_decision_surface(&decision_surface_audit_options(
5058 dispatch,
5059 &inputs,
5060 max_decisions,
5061 ))
5062}
5063
5064fn decision_surface_audit_args(max_decisions: usize) -> AuditDispatchArgs {
5065 AuditDispatchArgs {
5066 production_dead_code: false,
5067 production_health: false,
5068 production_dupes: false,
5069 dead_code_baseline: None,
5070 health_baseline: None,
5071 dupes_baseline: None,
5072 max_crap: None,
5073 coverage: None,
5074 coverage_root: None,
5075 no_css: true,
5076 css_deep: false,
5077 no_css_deep: false,
5078 gate: None,
5079 runtime_coverage: None,
5080 min_invocations_hot: 0,
5081 gate_marker: None,
5082 brief: true,
5083 max_decisions,
5084 walkthrough_guide: false,
5085 walkthrough_file: None,
5086 walkthrough: false,
5087 mark_viewed: Vec::new(),
5088 show_cleared: false,
5089 show_deprioritized: false,
5090 }
5091}
5092
5093fn decision_surface_audit_options<'a>(
5094 dispatch: &'a DispatchContext<'a>,
5095 inputs: &'a ResolvedAuditInputs,
5096 max_decisions: usize,
5097) -> audit::AuditOptions<'a> {
5098 let cli = dispatch.cli;
5099 audit::AuditOptions {
5100 root: dispatch.root,
5101 config_path: &cli.config,
5102 cache_dir: &inputs.cache_dir,
5103 output: dispatch.output,
5104 json_style: dispatch.json_style,
5105 no_cache: cli.no_cache,
5106 threads: dispatch.threads,
5107 quiet: dispatch.quiet,
5108 allow_remote_extends: cli.allow_remote_extends,
5109 changed_since: cli.changed_since.as_deref(),
5110 production: cli.production,
5111 production_dead_code: Some(inputs.production.dead_code),
5112 production_health: Some(inputs.production.health),
5113 production_dupes: Some(inputs.production.dupes),
5114 workspace: cli.workspace.as_deref(),
5115 changed_workspaces: cli.changed_workspaces.as_deref(),
5116 explain: cli.explain,
5117 explain_skipped: cli.explain_skipped,
5118 performance: cli.performance,
5119 group_by: cli.group_by,
5120 dead_code_baseline: inputs.dead_code_baseline.as_deref(),
5121 health_baseline: inputs.health_baseline.as_deref(),
5122 dupes_baseline: inputs.dupes_baseline.as_deref(),
5123 health_baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
5124 max_crap: None,
5125 coverage: None,
5126 coverage_root: None,
5127 gate: inputs.audit_cfg.gate,
5128 include_entry_exports: cli.include_entry_exports,
5129 css: false,
5131 css_deep: false,
5132 runtime_coverage: None,
5133 min_invocations_hot: 0,
5134 brief: true,
5135 max_decisions,
5136 walkthrough_guide: false,
5137 walkthrough: false,
5138 mark_viewed: &[],
5139 show_cleared: false,
5140 walkthrough_file: None,
5141 show_deprioritized: false,
5142 }
5143}
5144
5145struct HealthDispatchArgs<'a> {
5146 max_cyclomatic: Option<u16>,
5147 max_cognitive: Option<u16>,
5148 max_crap: Option<f64>,
5149 top: Option<usize>,
5150 sort: health::SortBy,
5151 complexity: bool,
5152 complexity_breakdown: bool,
5153 file_scores: bool,
5154 coverage_gaps: bool,
5155 hotspots: bool,
5156 ownership: bool,
5157 ownership_emails: Option<fallow_config::EmailMode>,
5158 targets: bool,
5159 type_coupling: bool,
5160 css: bool,
5161 effort: Option<EffortFilter>,
5162 score: bool,
5163 min_score: Option<f64>,
5164 min_severity: Option<fallow_output::FindingSeverity>,
5165 report_only: bool,
5166 since: Option<&'a str>,
5167 min_commits: Option<u32>,
5168 save_snapshot: Option<&'a Option<String>>,
5169 trend: bool,
5170 coverage: Option<&'a std::path::Path>,
5171 coverage_root: Option<&'a std::path::Path>,
5172 runtime_coverage: Option<&'a std::path::Path>,
5173 min_invocations_hot: u64,
5174 min_observation_volume: Option<u32>,
5175 low_traffic_threshold: Option<f64>,
5176}
5177
5178struct ResolvedHealthCoverageInputs {
5179 coverage: Option<PathBuf>,
5180 coverage_root: Option<PathBuf>,
5181}
5182
5183fn resolve_health_coverage_inputs(
5184 dispatch: &DispatchContext<'_>,
5185 cli_coverage: Option<&std::path::Path>,
5186 cli_coverage_root: Option<&std::path::Path>,
5187) -> Result<ResolvedHealthCoverageInputs, ExitCode> {
5188 let env_coverage = path_from_env("FALLOW_COVERAGE");
5189 let env_coverage_root = path_from_env("FALLOW_COVERAGE_ROOT");
5190 let needs_config_coverage = cli_coverage.is_none() && env_coverage.is_none();
5191 let needs_config_coverage_root = cli_coverage_root.is_none() && env_coverage_root.is_none();
5192 let config_health = if needs_config_coverage || needs_config_coverage_root {
5193 Some(
5194 load_config(
5195 dispatch.root,
5196 &dispatch.cli.config,
5197 LoadConfigArgs {
5198 output: dispatch.output,
5199 no_cache: dispatch.cli.no_cache,
5200 threads: dispatch.threads,
5201 production: dispatch.cli.production,
5202 quiet: dispatch.quiet,
5203 allow_remote_extends: dispatch.cli.allow_remote_extends,
5204 },
5205 )?
5206 .health,
5207 )
5208 } else {
5209 None
5210 };
5211
5212 Ok(ResolvedHealthCoverageInputs {
5213 coverage: cli_coverage
5214 .map(std::path::Path::to_path_buf)
5215 .or(env_coverage)
5216 .or_else(|| {
5217 config_health
5218 .as_ref()
5219 .and_then(|health| health.coverage.clone())
5220 }),
5221 coverage_root: cli_coverage_root
5222 .map(std::path::Path::to_path_buf)
5223 .or(env_coverage_root)
5224 .or_else(|| {
5225 config_health
5226 .as_ref()
5227 .and_then(|health| health.coverage_root.clone())
5228 }),
5229 })
5230}
5231
5232fn path_from_env(name: &str) -> Option<PathBuf> {
5233 std::env::var_os(name)
5234 .filter(|value| !value.is_empty())
5235 .map(PathBuf::from)
5236}
5237
5238fn validate_health_report_only_gate(
5239 report_only: bool,
5240 min_score: Option<f64>,
5241 min_severity: Option<fallow_output::FindingSeverity>,
5242 output: fallow_config::OutputFormat,
5243) -> Result<(), ExitCode> {
5244 if report_only && (min_score.is_some() || min_severity.is_some()) {
5245 return Err(emit_error(
5246 "--report-only cannot be combined with --min-score or --min-severity. \
5247 --report-only always exits 0; drop it to gate on score/severity, or \
5248 drop the gate flags to stay advisory.",
5249 2,
5250 output,
5251 ));
5252 }
5253
5254 Ok(())
5255}
5256
5257fn resolve_runtime_coverage_options(
5258 runtime_coverage: Option<&std::path::Path>,
5259 min_invocations_hot: u64,
5260 min_observation_volume: Option<u32>,
5261 low_traffic_threshold: Option<f64>,
5262 output: fallow_config::OutputFormat,
5263) -> Result<Option<fallow_engine::health::RuntimeCoverageOptions>, ExitCode> {
5264 let Some(path) = runtime_coverage else {
5265 return Ok(None);
5266 };
5267
5268 health::coverage::prepare_options(
5269 path,
5270 min_invocations_hot,
5271 min_observation_volume,
5272 low_traffic_threshold,
5273 output,
5274 )
5275 .map(Some)
5276}
5277
5278fn dispatch_health(dispatch: &DispatchContext<'_>, args: &HealthDispatchArgs<'_>) -> ExitCode {
5279 let cli = dispatch.cli;
5280 let root = dispatch.root;
5281 let (output, _quiet, _fail_on_issues) =
5282 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
5283 if let Err(code) = validate_health_report_only_gate(
5284 args.report_only,
5285 args.min_score,
5286 args.min_severity,
5287 output,
5288 ) {
5289 return code;
5290 }
5291 let runtime_coverage = match resolve_runtime_coverage_options(
5292 args.runtime_coverage,
5293 args.min_invocations_hot,
5294 args.min_observation_volume,
5295 args.low_traffic_threshold,
5296 output,
5297 ) {
5298 Ok(options) => options,
5299 Err(code) => return code,
5300 };
5301 let production = match resolve_production_modes(cli, root, output, false, false, false) {
5302 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::Health),
5303 Err(code) => return code,
5304 };
5305 let coverage_inputs =
5306 match resolve_health_coverage_inputs(dispatch, args.coverage, args.coverage_root) {
5307 Ok(inputs) => inputs,
5308 Err(code) => return code,
5309 };
5310 let run = derive_health_dispatch_run(args, output, &coverage_inputs, runtime_coverage);
5311 run_health_dispatch(dispatch, args, ResolvedHealthDispatch { run, production })
5312}
5313
5314fn derive_health_dispatch_run<'a>(
5315 args: &'a HealthDispatchArgs<'a>,
5316 output: fallow_config::OutputFormat,
5317 coverage_inputs: &'a ResolvedHealthCoverageInputs,
5318 runtime_coverage: Option<fallow_engine::health::RuntimeCoverageOptions>,
5319) -> fallow_engine::health::HealthRunOptions<'a> {
5320 let mut run = fallow_engine::health::derive_health_run_options(
5321 fallow_engine::health::HealthRunOptionsInput {
5322 output,
5323 thresholds: health_threshold_overrides(args),
5324 top: args.top,
5325 sort: args.sort.clone().into(),
5326 complexity: args.complexity,
5327 file_scores: args.file_scores,
5328 coverage_gaps: args.coverage_gaps,
5329 hotspots: args.hotspots,
5330 ownership: args.ownership,
5331 ownership_emails: args.ownership_emails,
5332 targets: args.targets,
5333 css: args.css,
5334 effort: args.effort.map(EffortFilter::to_estimate),
5335 score: args.score,
5336 gates: health_gate_options(args),
5337 snapshot_requested: args.save_snapshot.is_some(),
5338 trend: args.trend,
5339 since: args.since,
5340 min_commits: args.min_commits,
5341 coverage_inputs: health_coverage_inputs(coverage_inputs),
5342 runtime_coverage,
5343 },
5344 );
5345 if args.type_coupling && !run.sections.any_section {
5346 run.sections = fallow_engine::health::DerivedHealthSections {
5347 any_section: true,
5348 complexity: false,
5349 file_scores: false,
5350 coverage_gaps: false,
5351 hotspots: false,
5352 targets: false,
5353 css: false,
5354 score: false,
5355 force_full: false,
5356 score_only_output: false,
5357 };
5358 }
5359 run
5360}
5361
5362fn health_threshold_overrides(
5363 args: &HealthDispatchArgs<'_>,
5364) -> fallow_engine::health::HealthThresholdOverrides {
5365 fallow_engine::health::HealthThresholdOverrides {
5366 max_cyclomatic: args.max_cyclomatic,
5367 max_cognitive: args.max_cognitive,
5368 max_crap: args.max_crap,
5369 }
5370}
5371
5372fn health_gate_options(args: &HealthDispatchArgs<'_>) -> fallow_engine::health::HealthGateOptions {
5373 fallow_engine::health::HealthGateOptions {
5374 min_score: args.min_score,
5375 min_severity: args.min_severity,
5376 report_only: args.report_only,
5377 }
5378}
5379
5380fn health_coverage_inputs(
5381 coverage_inputs: &ResolvedHealthCoverageInputs,
5382) -> fallow_engine::health::HealthCoverageInputs<'_> {
5383 fallow_engine::health::HealthCoverageInputs {
5384 coverage: coverage_inputs.coverage.as_deref(),
5385 coverage_root: coverage_inputs.coverage_root.as_deref(),
5386 }
5387}
5388
5389struct ResolvedHealthDispatch<'a> {
5393 run: fallow_engine::health::HealthRunOptions<'a>,
5394 production: bool,
5395}
5396
5397fn run_health_dispatch(
5400 dispatch: &DispatchContext<'_>,
5401 args: &HealthDispatchArgs<'_>,
5402 resolved: ResolvedHealthDispatch<'_>,
5403) -> ExitCode {
5404 let cli = dispatch.cli;
5405 let (output, quiet, _fail_on_issues) =
5406 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
5407 let run = resolved.run;
5408 let sections = run.sections;
5409 let production = resolved.production;
5410 health::run_health(
5411 &HealthOptions {
5412 root: dispatch.root,
5413 config_path: &cli.config,
5414 output,
5415 no_cache: cli.no_cache,
5416 threads: dispatch.threads,
5417 quiet,
5418 thresholds: run.thresholds,
5419 top: run.top,
5420 sort: run.sort,
5421 production,
5422 production_override: Some(production),
5423 allow_remote_extends: cli.allow_remote_extends,
5424 changed_since: cli.changed_since.as_deref(),
5425 diff_index: None,
5426 use_shared_diff_index: true,
5427 workspace: cli.workspace.as_deref(),
5428 changed_workspaces: cli.changed_workspaces.as_deref(),
5429 baseline: cli.baseline.as_deref(),
5430 save_baseline: cli.save_baseline.as_deref(),
5431 baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
5432 baseline_mode_explicit: cli.baseline_mode.is_some(),
5433 complexity: sections.complexity,
5434 file_scores: sections.file_scores,
5435 coverage_gaps: sections.coverage_gaps,
5436 config_activates_coverage_gaps: !sections.any_section,
5437 hotspots: sections.hotspots,
5438 ownership: run.ownership,
5439 ownership_emails: run.ownership_emails,
5440 targets: sections.targets,
5441 css: sections.css,
5442 css_deep: false,
5443 force_full: sections.force_full,
5444 score_only_output: sections.score_only_output,
5445 enforce_coverage_gap_gate: true,
5446 effort: run.effort,
5447 score: sections.score,
5448 gates: run.gates,
5449 since: run.since,
5450 min_commits: run.min_commits,
5451 explain: cli.explain,
5452 summary: cli.summary,
5453 save_snapshot: args
5454 .save_snapshot
5455 .map(|opt| PathBuf::from(opt.as_deref().unwrap_or_default())),
5456 trend: args.trend,
5457 coverage_inputs: run.coverage_inputs,
5458 performance: cli.performance,
5459 runtime_coverage: run.runtime_coverage,
5460 churn_file: cli.churn_file.as_deref(),
5461 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
5462 complexity_breakdown: args.complexity_breakdown,
5463 group_by: cli.group_by.map(Into::into),
5464 },
5465 dispatch.json_style,
5466 &health::TypeAwareHealthOptions {
5467 enabled: cli.type_aware_override(),
5468 requested: args.type_coupling,
5469 unfiltered: health_type_coupling_is_default_section(args),
5470 projects: &cli.type_aware_project,
5471 require: cli.type_aware_require.map(Into::into),
5472 },
5473 )
5474}
5475
5476fn health_type_coupling_is_default_section(args: &HealthDispatchArgs<'_>) -> bool {
5477 !args.complexity
5478 && !args.file_scores
5479 && !args.coverage_gaps
5480 && !args.hotspots
5481 && !args.ownership
5482 && !args.targets
5483 && !args.css
5484 && !args.score
5485 && args.min_score.is_none()
5486 && args.min_severity.is_none()
5487 && args.runtime_coverage.is_none()
5488}
5489
5490#[cfg(test)]
5491mod tests {
5492 use super::*;
5493
5494 #[test]
5498 fn cli_definition_has_no_flag_collisions() {
5499 use clap::CommandFactory;
5500 Cli::command().debug_assert();
5501 }
5502
5503 #[test]
5504 fn impact_statusline_subcommand_parses() {
5505 use clap::Parser;
5506
5507 let cli = Cli::try_parse_from(["fallow", "impact", "statusline"]).expect("argv parses");
5508 assert!(matches!(
5509 cli.command,
5510 Some(Command::Impact {
5511 subcommand: Some(ImpactCli::Statusline),
5512 ..
5513 })
5514 ));
5515 }
5516
5517 #[test]
5518 fn impact_statusline_bypasses_command_epilogue() {
5519 use clap::Parser;
5520
5521 let statusline =
5522 Cli::try_parse_from(["fallow", "impact", "statusline"]).expect("argv parses");
5523 assert!(is_impact_statusline(&statusline));
5524
5525 let status = Cli::try_parse_from(["fallow", "impact", "status"]).expect("argv parses");
5526 assert!(!is_impact_statusline(&status));
5527
5528 let all_statusline =
5529 Cli::try_parse_from(["fallow", "impact", "--all", "statusline"]).expect("argv parses");
5530 assert!(!is_impact_statusline(&all_statusline));
5531 }
5532
5533 #[test]
5534 fn regression_baseline_help_explains_the_default_destination() {
5535 use clap::CommandFactory;
5536 let help = Cli::command().render_long_help().to_string();
5537
5538 assert!(help.contains("Omit PATH to update regression.baseline"));
5539 assert!(help.contains("discovered fallow config"));
5540 assert!(help.contains("create .fallowrc.json when none exists"));
5541 }
5542
5543 #[test]
5547 fn after_help_lists_every_task_matrix_command() {
5548 for row in crate::task_matrix::TASK_MATRIX {
5549 assert!(
5550 TOP_LEVEL_AFTER_HELP.contains(row.command),
5551 "root --help cheat sheet is missing task-matrix command '{}'; \
5552 update TOP_LEVEL_AFTER_HELP to match TASK_MATRIX",
5553 row.command
5554 );
5555 }
5556 }
5557
5558 #[test]
5562 fn high_value_commands_route_to_distinct_workflows() {
5563 use clap::Parser;
5564 use fallow_config::OutputFormat;
5565
5566 let distinct = [
5567 (vec!["fallow", "impact"], telemetry::Workflow::Impact),
5568 (vec!["fallow", "security"], telemetry::Workflow::Security),
5569 (vec!["fallow", "fix"], telemetry::Workflow::Fix),
5570 (
5571 vec!["fallow", "explain", "unused-exports"],
5572 telemetry::Workflow::Explain,
5573 ),
5574 (
5575 vec!["fallow", "watch"],
5576 telemetry::Workflow::CodeQualityReview,
5577 ),
5578 (
5579 vec!["fallow", "list"],
5580 telemetry::Workflow::ProjectInventory,
5581 ),
5582 (
5583 vec!["fallow", "workspaces"],
5584 telemetry::Workflow::ProjectInventory,
5585 ),
5586 (
5587 vec!["fallow", "schema"],
5588 telemetry::Workflow::ProjectInventory,
5589 ),
5590 (vec!["fallow", "init"], telemetry::Workflow::Setup),
5591 (
5592 vec!["fallow", "hooks", "install", "--target", "git"],
5593 telemetry::Workflow::Setup,
5594 ),
5595 (vec!["fallow", "config-schema"], telemetry::Workflow::Setup),
5596 (vec!["fallow", "plugin-schema"], telemetry::Workflow::Setup),
5597 (
5598 vec!["fallow", "rule-pack-schema"],
5599 telemetry::Workflow::Setup,
5600 ),
5601 (vec!["fallow", "config"], telemetry::Workflow::Setup),
5602 (
5603 vec!["fallow", "ci-template", "gitlab"],
5604 telemetry::Workflow::Setup,
5605 ),
5606 (vec!["fallow", "migrate"], telemetry::Workflow::Setup),
5607 (
5608 vec!["fallow", "telemetry", "status"],
5609 telemetry::Workflow::Setup,
5610 ),
5611 (vec!["fallow", "setup-hooks"], telemetry::Workflow::Setup),
5612 (
5613 vec!["fallow", "audit-cache", "remove", "--root", "."],
5614 telemetry::Workflow::Setup,
5615 ),
5616 (
5617 vec!["fallow", "license", "status"],
5618 telemetry::Workflow::License,
5619 ),
5620 ];
5621 for (argv, expected) in distinct {
5622 let cli = Cli::try_parse_from(&argv).expect("argv parses");
5623 assert_eq!(
5624 telemetry_workflow_for_command(cli.command.as_ref(), OutputFormat::Json),
5625 expected,
5626 "{argv:?} should map to {expected:?}"
5627 );
5628 }
5629 }
5630
5631 #[test]
5636 fn version_flag_accepts_lower_v_upper_v_and_long() {
5637 use clap::CommandFactory;
5638 for argv in [["fallow", "-v"], ["fallow", "-V"], ["fallow", "--version"]] {
5639 let err = Cli::command()
5640 .try_get_matches_from(argv)
5641 .expect_err("version flag should short-circuit parsing");
5642 assert_eq!(
5643 err.kind(),
5644 clap::error::ErrorKind::DisplayVersion,
5645 "{argv:?} should trigger the Version action"
5646 );
5647 }
5648 }
5649
5650 #[test]
5655 fn cli_help_text_contains_no_implementation_status_wording() {
5656 use clap::CommandFactory;
5657 let mut root = Cli::command();
5658 let mut violations: Vec<(String, String)> = Vec::new();
5659 visit_help(&mut root, "fallow", &mut violations);
5660 assert!(
5661 violations.is_empty(),
5662 "found implementation-status wording in --help output:\n{}",
5663 violations
5664 .iter()
5665 .map(|(cmd, line)| format!(" {cmd}: {line}"))
5666 .collect::<Vec<_>>()
5667 .join("\n")
5668 );
5669 }
5670
5671 #[test]
5672 fn top_level_help_groups_commands_by_workflow() {
5673 use clap::CommandFactory;
5674 let help = Cli::command().render_long_help().to_string();
5675 let expected_order = [
5676 "Analysis:",
5677 " dead-code",
5678 " dupes",
5679 " health",
5680 " flags",
5681 " security",
5682 " audit",
5683 "Workflow:",
5684 " watch",
5685 " fix",
5686 "Project inspection:",
5687 " list",
5688 " workspaces",
5689 " explain",
5690 " impact",
5691 " viz",
5692 "Setup and configuration:",
5693 " init",
5694 " recommend",
5695 " migrate",
5696 " config",
5697 " config-schema",
5698 " plugin-schema",
5699 " plugin-check",
5700 " rule-pack-schema",
5701 "Automation and CI:",
5702 " ci",
5703 " ci-template",
5704 " hooks",
5705 " setup-hooks",
5706 "Runtime coverage:",
5707 " coverage",
5708 " license",
5709 "Reference:",
5710 " schema",
5711 " help",
5712 "Options:",
5713 ];
5714 let mut cursor = 0;
5715 for needle in expected_order {
5716 let Some(offset) = help[cursor..].find(needle) else {
5717 panic!("top-level help missing `{needle}` after byte {cursor}:\n{help}");
5718 };
5719 cursor += offset + needle.len();
5720 }
5721 }
5722
5723 #[test]
5724 fn security_help_hides_globals_rejected_by_security_validator() {
5725 let help = render_security_help(SecurityHelpTarget::Parent);
5726
5727 for long in SECURITY_UNSUPPORTED_GLOBAL_LONGS {
5728 assert!(
5729 !help_contains_long_flag(&help, long),
5730 "security help must hide unsupported --{long}:\n{help}"
5731 );
5732 }
5733
5734 for long in [
5735 "root",
5736 "config",
5737 "format",
5738 "quiet",
5739 "no-cache",
5740 "threads",
5741 "changed-since",
5742 "diff-file",
5743 "diff-stdin",
5744 "workspace",
5745 "changed-workspaces",
5746 "ci",
5747 "fail-on-issues",
5748 "sarif-file",
5749 "summary",
5750 "output-file",
5751 "max-file-size",
5752 "explain",
5753 "surface",
5754 ] {
5755 assert!(
5756 help_contains_long_flag(&help, long),
5757 "security help must keep supported --{long}:\n{help}"
5758 );
5759 }
5760 }
5761
5762 #[test]
5763 fn security_help_detection_covers_subcommand_and_help_alias_forms() {
5764 assert_eq!(
5765 security_help_target(["security", "--help"]),
5766 Some(SecurityHelpTarget::Parent)
5767 );
5768 assert_eq!(
5769 security_help_target(["security", "-h"]),
5770 Some(SecurityHelpTarget::Parent)
5771 );
5772 assert_eq!(
5773 security_help_target(["--format", "json", "security", "--help"]),
5774 Some(SecurityHelpTarget::Parent)
5775 );
5776 assert_eq!(
5777 security_help_target(["help", "security"]),
5778 Some(SecurityHelpTarget::Parent)
5779 );
5780 assert_eq!(
5781 security_help_target(["security", "survivors", "--help"]),
5782 Some(SecurityHelpTarget::Survivors)
5783 );
5784 assert_eq!(
5785 security_help_target(["security", "survivors", "-h"]),
5786 Some(SecurityHelpTarget::Survivors)
5787 );
5788 assert_eq!(
5789 security_help_target(["help", "security", "survivors"]),
5790 Some(SecurityHelpTarget::Survivors)
5791 );
5792 assert_eq!(
5793 security_help_target(["security", "blind-spots", "--help"]),
5794 Some(SecurityHelpTarget::BlindSpots)
5795 );
5796 assert_eq!(
5797 security_help_target(["help", "security", "blind-spots"]),
5798 Some(SecurityHelpTarget::BlindSpots)
5799 );
5800 assert_eq!(security_help_target(["health", "--help"]), None);
5801 assert_eq!(security_help_target(["help", "health"]), None);
5802 }
5803
5804 #[test]
5805 fn security_unsupported_global_validator_matches_hidden_help_contract() {
5806 for (argv, expected) in [
5807 (vec!["fallow", "security", "--performance"], "--performance"),
5808 (
5809 vec!["fallow", "security", "--baseline", "base.json"],
5810 "--baseline",
5811 ),
5812 (
5813 vec!["fallow", "security", "--dupes-mode", "weak"],
5814 "--dupes-mode",
5815 ),
5816 ] {
5817 let cli = Cli::try_parse_from(argv).expect("security global parses before validation");
5818 assert_eq!(unsupported_security_global(&cli), Some(expected));
5819 }
5820
5821 let explain = Cli::try_parse_from(["fallow", "security", "--explain"])
5822 .expect("security --explain parses");
5823 assert_eq!(unsupported_security_global(&explain), None);
5824 }
5825
5826 #[test]
5827 fn programmatic_common_options_track_analysis_affecting_cli_globals() {
5828 use clap::CommandFactory;
5829
5830 let cli_flags: std::collections::BTreeSet<String> = Cli::command()
5831 .get_arguments()
5832 .filter(|arg| arg.is_global_set())
5833 .filter_map(|arg| arg.get_long().map(str::to_owned))
5834 .filter(|name| {
5835 matches!(
5836 name.as_str(),
5837 "root"
5838 | "config"
5839 | "allow-remote-extends"
5840 | "no-cache"
5841 | "threads"
5842 | "changed-since"
5843 | "diff-file"
5844 | "production"
5845 | "workspace"
5846 | "changed-workspaces"
5847 | "explain"
5848 )
5849 })
5850 .collect();
5851 let programmatic_flags: std::collections::BTreeSet<String> =
5852 fallow_api::COMMON_ANALYSIS_OPTION_FLAGS
5853 .iter()
5854 .map(|flag| (*flag).to_owned())
5855 .collect();
5856
5857 assert_eq!(programmatic_flags, cli_flags);
5858 }
5859
5860 #[test]
5861 fn dead_code_registry_filter_flags_are_exposed_by_clap() {
5862 use clap::CommandFactory;
5863
5864 let cli = Cli::command();
5865 let dead_code = cli
5866 .get_subcommands()
5867 .find(|command| command.get_name() == "dead-code")
5868 .expect("dead-code subcommand is registered");
5869 let cli_flags: std::collections::BTreeSet<String> = dead_code
5870 .get_arguments()
5871 .filter_map(|arg| arg.get_long().map(|long| format!("--{long}")))
5872 .collect();
5873
5874 for flag in fallow_types::issue_meta::DEAD_CODE_FILTER_FLAGS.iter() {
5875 assert!(
5876 cli_flags.contains(*flag),
5877 "registry filter flag {flag} is missing from dead-code clap args"
5878 );
5879 }
5880 }
5881
5882 fn help_contains_long_flag(help: &str, long: &str) -> bool {
5883 let flag = format!("--{long}");
5884 help.split(|c: char| c.is_whitespace() || c == ',' || c == '[' || c == ']')
5885 .any(|token| token == flag)
5886 }
5887
5888 fn visit_help(cmd: &mut clap::Command, path: &str, violations: &mut Vec<(String, String)>) {
5889 let help = cmd.render_long_help().to_string();
5890 for line in scan_forbidden(&help) {
5891 violations.push((path.to_owned(), line));
5892 }
5893 let names: Vec<String> = cmd
5894 .get_subcommands()
5895 .map(|sub| sub.get_name().to_owned())
5896 .collect();
5897 for name in names {
5898 if name == "help" {
5899 continue;
5900 }
5901 if let Some(sub) = cmd.find_subcommand_mut(&name) {
5902 let sub_path = format!("{path} {name}");
5903 visit_help(sub, &sub_path, violations);
5904 }
5905 }
5906 }
5907
5908 fn scan_forbidden(s: &str) -> Vec<String> {
5909 let lower = s.to_ascii_lowercase();
5910 let mut out = Vec::new();
5911 for word in ["stub", "placeholder"] {
5912 if let Some(idx) = find_whole_word(&lower, word) {
5913 out.push(extract_line(s, idx));
5914 }
5915 }
5916 if let Some(idx) = lower.find("not yet") {
5917 out.push(extract_line(s, idx));
5918 }
5919 out
5920 }
5921
5922 fn find_whole_word(haystack: &str, word: &str) -> Option<usize> {
5923 let bytes = haystack.as_bytes();
5924 let mut start = 0;
5925 while let Some(rel) = haystack[start..].find(word) {
5926 let abs = start + rel;
5927 let before_ok = abs == 0 || !bytes[abs - 1].is_ascii_alphanumeric();
5928 let after_idx = abs + word.len();
5929 let after_ok = after_idx >= bytes.len() || !bytes[after_idx].is_ascii_alphanumeric();
5930 if before_ok && after_ok {
5931 return Some(abs);
5932 }
5933 start = abs + word.len();
5934 }
5935 None
5936 }
5937
5938 fn extract_line(s: &str, byte_idx: usize) -> String {
5939 let line_start = s[..byte_idx].rfind('\n').map_or(0, |i| i + 1);
5940 let line_end = s[byte_idx..].find('\n').map_or(s.len(), |i| byte_idx + i);
5941 s[line_start..line_end].trim().to_owned()
5942 }
5943
5944 #[test]
5945 fn emit_error_returns_given_exit_code() {
5946 let code = emit_error("test error", 2, fallow_config::OutputFormat::Human);
5947 assert_eq!(code, ExitCode::from(2));
5948 }
5949
5950 fn telemetry_run_for_mode(mode: telemetry::AnalysisMode) -> TelemetryRun {
5951 TelemetryRun {
5952 workflow: telemetry::Workflow::Health,
5953 output: fallow_config::OutputFormat::Json,
5954 quiet: true,
5955 start: std::time::Instant::now(),
5956 context: telemetry::WorkflowContext {
5957 run_scope: telemetry::RunScope::FullProject,
5958 config_shape: telemetry::ConfigShape::Default,
5959 output_destination: telemetry::OutputDestination::Stdout,
5960 analysis_mode: mode,
5961 },
5962 }
5963 }
5964
5965 #[test]
5966 fn fallback_failure_reason_skips_success_and_findings() {
5967 let run = telemetry_run_for_mode(telemetry::AnalysisMode::Static);
5968
5969 assert_eq!(fallback_failure_reason_for(&run, ExitCode::SUCCESS), None);
5970 assert_eq!(fallback_failure_reason_for(&run, ExitCode::from(1)), None);
5971 }
5972
5973 #[test]
5974 fn fallback_failure_reason_classifies_network_auth_and_analysis() {
5975 let static_run = telemetry_run_for_mode(telemetry::AnalysisMode::Static);
5976 let cloud_run = telemetry_run_for_mode(telemetry::AnalysisMode::ProductionCoverage);
5977
5978 assert_eq!(
5979 fallback_failure_reason_for(&static_run, ExitCode::from(api::NETWORK_EXIT_CODE)),
5980 Some(telemetry::FailureReason::Network),
5981 );
5982 assert_eq!(
5983 fallback_failure_reason_for(&static_run, ExitCode::from(12)),
5984 Some(telemetry::FailureReason::Auth),
5985 );
5986 assert_eq!(
5987 fallback_failure_reason_for(&cloud_run, ExitCode::from(3)),
5988 Some(telemetry::FailureReason::Auth),
5989 );
5990 assert_eq!(
5991 fallback_failure_reason_for(&static_run, ExitCode::from(2)),
5992 Some(telemetry::FailureReason::Analysis),
5993 );
5994 }
5995
5996 #[test]
5997 fn bare_coverage_flags_parse_without_subcommand() {
5998 let cli = Cli::try_parse_from([
5999 "fallow",
6000 "--coverage",
6001 "coverage/coverage-final.json",
6002 "--coverage-root",
6003 "/ci/workspace",
6004 ])
6005 .expect("bare combined coverage flags should parse");
6006 assert!(cli.command.is_none());
6007 assert_eq!(
6008 cli.coverage.as_deref(),
6009 Some(std::path::Path::new("coverage/coverage-final.json"))
6010 );
6011 assert_eq!(
6012 cli.coverage_root.as_deref(),
6013 Some(std::path::Path::new("/ci/workspace"))
6014 );
6015 }
6016
6017 #[test]
6018 fn bare_coverage_before_subcommand_is_detectable() {
6019 let cli = Cli::try_parse_from([
6020 "fallow",
6021 "--coverage",
6022 "coverage/coverage-final.json",
6023 "dead-code",
6024 ])
6025 .expect("clap should parse pre-subcommand bare coverage for custom rejection");
6026 assert!(cli.command.is_some());
6027 assert!(cli_has_bare_coverage_input(&cli));
6028 let message = bare_coverage_subcommand_error_message();
6029 assert!(message.contains("bare combined-mode flags"));
6030 assert!(message.contains("fallow health --coverage <coverage-final.json>"));
6031 }
6032
6033 #[test]
6034 fn subcommand_coverage_flag_keeps_regular_clap_error() {
6035 let Err(err) = Cli::try_parse_from(["fallow", "dead-code", "--coverage"]) else {
6036 panic!("dead-code --coverage should fail to parse");
6037 };
6038 assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
6039 }
6040
6041 #[test]
6042 fn type_aware_flags_parse_for_semantic_analysis() {
6043 let cli = Cli::try_parse_from([
6044 "fallow",
6045 "dead-code",
6046 "--unused-class-members",
6047 "--type-aware",
6048 "--type-aware-project",
6049 "tsconfig.json",
6050 "--type-aware-project",
6051 "packages/web/tsconfig.json",
6052 ])
6053 .expect("type-aware flag should parse");
6054 assert!(cli.type_aware);
6055 assert_eq!(
6056 cli.type_aware_project,
6057 [
6058 PathBuf::from("tsconfig.json"),
6059 PathBuf::from("packages/web/tsconfig.json")
6060 ]
6061 );
6062 let Some(Command::Check {
6063 unused_class_members,
6064 ..
6065 }) = cli.command
6066 else {
6067 panic!("dead-code should parse as the check command");
6068 };
6069 assert!(unused_class_members);
6070 }
6071
6072 #[test]
6073 fn no_type_aware_conflicts_with_type_aware() {
6074 let Err(err) = Cli::try_parse_from(["fallow", "audit", "--type-aware", "--no-type-aware"])
6075 else {
6076 panic!("--no-type-aware must conflict with --type-aware");
6077 };
6078 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
6079 }
6080
6081 #[test]
6082 fn no_type_aware_forces_semantic_analysis_off() {
6083 let cli = Cli::try_parse_from(["fallow", "audit", "--no-type-aware"])
6084 .expect("--no-type-aware should parse on audit");
6085 assert_eq!(cli.type_aware_override(), Some(false));
6086
6087 let cli = Cli::try_parse_from(["fallow", "dead-code", "--type-aware"])
6088 .expect("--type-aware should parse");
6089 assert_eq!(cli.type_aware_override(), Some(true));
6090
6091 let cli = Cli::try_parse_from(["fallow", "dead-code"]).expect("bare command should parse");
6092 assert_eq!(cli.type_aware_override(), None);
6093 }
6094
6095 #[test]
6096 fn type_aware_status_output_hides_host_paths() {
6097 let root = Path::new("/private/work/project");
6098 let output = type_aware_status_output(
6099 root,
6100 fallow_api::TypeAwareStatus {
6101 available: false,
6102 discovery_source: Some("environment-override"),
6103 companion_path: Some(PathBuf::from("/private/tools/fallow-type-aware")),
6104 package_version: None,
6105 protocol_version: 6,
6106 backend_family: None,
6107 backend_version: None,
6108 remediation: Some(
6109 "failed to launch /private/tools/fallow-type-aware from /private/work/project"
6110 .to_string(),
6111 ),
6112 },
6113 );
6114
6115 assert_eq!(output.companion_path.as_deref(), Some("fallow-type-aware"));
6116 let remediation = output.remediation.expect("remediation");
6117 assert!(!remediation.contains("/private/"));
6118 assert!(remediation.contains("fallow-type-aware"));
6119 }
6120
6121 #[test]
6122 fn format_parsing_covers_all_variants() {
6123 assert!(matches!(parse_format_arg("json"), Some(Format::Json)));
6124 assert!(matches!(parse_format_arg("JSON"), Some(Format::Json)));
6125 assert!(matches!(parse_format_arg("human"), Some(Format::Human)));
6126 assert!(matches!(parse_format_arg("sarif"), Some(Format::Sarif)));
6127 assert!(matches!(parse_format_arg("compact"), Some(Format::Compact)));
6128 assert!(matches!(
6129 parse_format_arg("markdown"),
6130 Some(Format::Markdown)
6131 ));
6132 assert!(matches!(parse_format_arg("md"), Some(Format::Markdown)));
6133 assert!(matches!(
6134 parse_format_arg("codeclimate"),
6135 Some(Format::CodeClimate)
6136 ));
6137 assert!(matches!(
6138 parse_format_arg("gitlab-codequality"),
6139 Some(Format::CodeClimate)
6140 ));
6141 assert!(matches!(
6142 parse_format_arg("gitlab-code-quality"),
6143 Some(Format::CodeClimate)
6144 ));
6145 assert!(matches!(
6146 parse_format_arg("pr-comment-github"),
6147 Some(Format::PrCommentGithub)
6148 ));
6149 assert!(matches!(
6150 parse_format_arg("pr-comment-gitlab"),
6151 Some(Format::PrCommentGitlab)
6152 ));
6153 assert!(matches!(
6154 parse_format_arg("review-github"),
6155 Some(Format::ReviewGithub)
6156 ));
6157 assert!(matches!(
6158 parse_format_arg("review-gitlab"),
6159 Some(Format::ReviewGitlab)
6160 ));
6161 assert!(matches!(parse_format_arg("badge"), Some(Format::Badge)));
6162 assert!(parse_format_arg("xml").is_none());
6163 assert!(parse_format_arg("").is_none());
6164 }
6165
6166 #[test]
6167 fn quiet_parsing_logic() {
6168 let parse = |s: &str| -> bool { s == "1" || s.eq_ignore_ascii_case("true") };
6169 assert!(parse("1"));
6170 assert!(parse("true"));
6171 assert!(parse("TRUE"));
6172 assert!(parse("True"));
6173 assert!(!parse("0"));
6174 assert!(!parse("false"));
6175 assert!(!parse("yes"));
6176 }
6177
6178 #[test]
6179 fn tracing_filter_defaults_to_warn_without_env() {
6180 assert_eq!(build_tracing_filter(None).to_string(), "warn");
6181 }
6182
6183 #[test]
6184 fn tracing_filter_respects_explicit_env_directives() {
6185 assert_eq!(build_tracing_filter(Some("info")).to_string(), "info");
6186 }
6187
6188 #[test]
6189 fn tracing_filter_treats_empty_env_as_off() {
6190 assert_eq!(build_tracing_filter(Some("")).to_string(), "off");
6191 assert_eq!(build_tracing_filter(Some(" ")).to_string(), "off");
6192 }
6193}