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, global = true, value_name = "RUN_ID", hide = true)]
302 parent_run: Option<String>,
303
304 #[arg(long, global = true)]
306 save_baseline: Option<PathBuf>,
307
308 #[arg(long, global = true)]
311 production: bool,
312
313 #[arg(long = "no-production", global = true, conflicts_with = "production")]
317 no_production: bool,
318
319 #[arg(long = "production-dead-code")]
321 production_dead_code: bool,
322
323 #[arg(long = "production-health")]
325 production_health: bool,
326
327 #[arg(long = "production-dupes")]
329 production_dupes: bool,
330
331 #[arg(short, long, global = true, value_delimiter = ',')]
335 workspace: Option<Vec<String>>,
336
337 #[arg(long, global = true, value_name = "REF")]
340 changed_workspaces: Option<String>,
341
342 #[arg(long, global = true)]
344 group_by: Option<GroupBy>,
345
346 #[arg(long, global = true)]
348 performance: bool,
349
350 #[arg(long, global = true)]
352 explain: bool,
353
354 #[arg(long, global = true)]
356 explain_skipped: bool,
357
358 #[arg(long, global = true)]
360 summary: bool,
361
362 #[arg(long, global = true)]
364 ci: bool,
365
366 #[arg(long, global = true)]
368 fail_on_issues: bool,
369
370 #[arg(long, global = true, value_name = "PATH")]
372 sarif_file: Option<PathBuf>,
373
374 #[arg(short = 'o', long, global = true, value_name = "PATH")]
378 output_file: Option<PathBuf>,
379
380 #[arg(
389 long = "report-path-prefix",
390 visible_alias = "annotations-path-prefix",
391 global = true,
392 value_name = "PREFIX"
393 )]
394 report_path_prefix: Option<String>,
395
396 #[arg(long, global = true)]
398 fail_on_regression: bool,
399
400 #[arg(long, global = true, value_name = "TOLERANCE", default_value = "0")]
402 tolerance: String,
403
404 #[arg(long, global = true, value_name = "PATH")]
406 regression_baseline: Option<PathBuf>,
407
408 #[expect(
412 clippy::option_option,
413 reason = "clap pattern: None=not passed, Some(None)=flag only (write to config), Some(Some(path))=write to file"
414 )]
415 #[arg(long, global = true, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
416 save_regression_baseline: Option<Option<String>>,
417
418 #[arg(long, value_delimiter = ',')]
420 only: Vec<AnalysisKind>,
421
422 #[arg(long, value_delimiter = ',')]
424 skip: Vec<AnalysisKind>,
425
426 #[arg(long = "dupes-mode", global = true)]
428 dupes_mode: Option<DupesMode>,
429
430 #[arg(long = "dupes-threshold", global = true)]
432 dupes_threshold: Option<f64>,
433
434 #[arg(long = "dupes-min-tokens", global = true)]
436 dupes_min_tokens: Option<usize>,
437
438 #[arg(long = "dupes-min-lines", global = true)]
440 dupes_min_lines: Option<usize>,
441
442 #[arg(long = "dupes-min-occurrences", global = true, value_parser = parse_min_occurrences)]
444 dupes_min_occurrences: Option<usize>,
445
446 #[arg(long = "dupes-skip-local", global = true)]
448 dupes_skip_local: bool,
449
450 #[arg(long = "dupes-cross-language", global = true)]
452 dupes_cross_language: bool,
453
454 #[arg(long = "dupes-ignore-imports", global = true)]
457 dupes_ignore_imports: bool,
458
459 #[arg(
462 long = "dupes-no-ignore-imports",
463 global = true,
464 conflicts_with = "dupes_ignore_imports"
465 )]
466 dupes_no_ignore_imports: bool,
467
468 #[arg(long)]
470 score: bool,
471
472 #[arg(long)]
474 trend: bool,
475
476 #[expect(
479 clippy::option_option,
480 reason = "clap pattern: None=not passed, Some(None)=default path, Some(Some(path))=custom path"
481 )]
482 #[arg(long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
483 save_snapshot: Option<Option<String>>,
484
485 #[arg(long, value_name = "PATH")]
488 coverage: Option<PathBuf>,
489
490 #[arg(long = "coverage-root", value_name = "PATH")]
493 coverage_root: Option<PathBuf>,
494
495 #[arg(long, global = true)]
497 include_entry_exports: bool,
498
499 #[arg(long, global = true)]
502 type_aware: bool,
503
504 #[arg(long, global = true, value_name = "PATH", action = clap::ArgAction::Append)]
506 type_aware_project: Vec<PathBuf>,
507
508 #[arg(long, global = true, value_enum)]
510 type_aware_require: Option<TypeAwareRequireArg>,
511}
512
513#[derive(Clone, Copy, Subcommand)]
514enum TypeAwareCli {
515 Status,
517}
518
519#[derive(Subcommand)]
520enum Command {
521 #[command(name = "dead-code", alias = "check")]
523 Check {
524 #[arg(long)]
526 unused_files: bool,
527
528 #[arg(long)]
530 unused_exports: bool,
531
532 #[arg(long)]
534 unused_deps: bool,
535
536 #[arg(long)]
538 unused_types: bool,
539
540 #[arg(long)]
542 private_type_leaks: bool,
543
544 #[arg(long)]
546 unused_enum_members: bool,
547
548 #[arg(long)]
550 unused_class_members: bool,
551
552 #[arg(long)]
554 unused_store_members: bool,
555
556 #[arg(long)]
558 unprovided_injects: bool,
559
560 #[arg(long)]
562 unrendered_components: bool,
563
564 #[arg(long)]
566 unused_component_props: bool,
567
568 #[arg(long)]
570 unused_component_emits: bool,
571
572 #[arg(long)]
574 unused_component_inputs: bool,
575
576 #[arg(long)]
578 unused_component_outputs: bool,
579
580 #[arg(long)]
582 unused_svelte_events: bool,
583
584 #[arg(long)]
586 unused_server_actions: bool,
587
588 #[arg(long)]
590 unused_load_data_keys: bool,
591
592 #[arg(long)]
594 unresolved_imports: bool,
595
596 #[arg(long)]
598 unlisted_deps: bool,
599
600 #[arg(long)]
602 duplicate_exports: bool,
603
604 #[arg(long)]
606 circular_deps: bool,
607
608 #[arg(long)]
610 re_export_cycles: bool,
611
612 #[arg(long)]
614 boundary_violations: bool,
615
616 #[arg(long)]
618 policy_violations: bool,
619
620 #[arg(long)]
622 stale_suppressions: bool,
623
624 #[arg(long)]
626 unused_catalog_entries: bool,
627
628 #[arg(long)]
630 empty_catalog_groups: bool,
631
632 #[arg(long)]
634 unresolved_catalog_references: bool,
635
636 #[arg(long)]
638 unused_dependency_overrides: bool,
639
640 #[arg(long)]
642 misconfigured_dependency_overrides: bool,
643
644 #[arg(long)]
646 include_dupes: bool,
647
648 #[arg(long, value_name = "FILE:EXPORT")]
650 trace: Option<String>,
651
652 #[arg(long, value_name = "PATH")]
654 trace_file: Option<String>,
655
656 #[arg(long, value_name = "PACKAGE")]
658 trace_dependency: Option<String>,
659
660 #[arg(long, value_name = "PATH")]
664 impact_closure: Option<String>,
665
666 #[arg(long, value_name = "FILE:EXPORT")]
668 symbol_impact: Option<String>,
669
670 #[arg(long)]
672 top: Option<usize>,
673
674 #[arg(long, value_name = "PATH")]
678 file: Vec<std::path::PathBuf>,
679 },
680
681 Watch {
683 #[arg(long)]
685 no_clear: bool,
686 },
687
688 TypeAware {
690 #[command(subcommand)]
691 subcommand: TypeAwareCli,
692 },
693
694 Inspect {
696 #[arg(
698 long,
699 value_name = "PATH",
700 conflicts_with = "symbol",
701 required_unless_present = "symbol"
702 )]
703 file: Option<String>,
704
705 #[arg(long, value_name = "FILE:EXPORT", conflicts_with = "file")]
707 symbol: Option<String>,
708
709 #[arg(long)]
714 symbol_chain: bool,
715
716 #[arg(long)]
719 churn: bool,
720 },
721
722 Trace {
731 #[arg(value_name = "FILE:SYMBOL")]
733 symbol: String,
734
735 #[arg(long)]
738 callers: bool,
739
740 #[arg(long)]
743 callees: bool,
744
745 #[arg(long, value_name = "N")]
748 depth: Option<u32>,
749 },
750
751 Fix {
766 #[arg(long)]
768 dry_run: bool,
769
770 #[arg(long, alias = "force")]
772 yes: bool,
773
774 #[arg(long)]
781 no_create_config: bool,
782 },
783
784 Init {
793 #[arg(long)]
795 toml: bool,
796
797 #[arg(long, conflicts_with_all = ["toml", "hooks", "branch"])]
799 agents: bool,
800
801 #[arg(long)]
805 hooks: bool,
806
807 #[arg(long, requires = "hooks")]
809 branch: Option<String>,
810
811 #[arg(long, conflicts_with_all = ["toml", "agents", "hooks", "branch"])]
815 decline: bool,
816 },
817
818 Hooks {
825 #[command(subcommand)]
826 subcommand: HooksCli,
827 },
828
829 Ci {
831 #[command(subcommand)]
832 subcommand: CiCli,
833 },
834
835 ConfigSchema,
837
838 PluginSchema,
840
841 PluginCheck,
843
844 RulePackSchema,
846
847 RulePack {
849 #[command(subcommand)]
850 subcommand: RulePackCli,
851 },
852
853 Guard {
855 #[arg(required = true, num_args = 1..)]
857 files: Vec<String>,
858 },
859
860 Config {
878 #[arg(long)]
880 path: bool,
881 },
882
883 Recommend,
891
892 List {
894 #[arg(long)]
896 entry_points: bool,
897
898 #[arg(long)]
900 files: bool,
901
902 #[arg(long)]
904 plugins: bool,
905
906 #[arg(long)]
908 boundaries: bool,
909
910 #[arg(long)]
914 workspaces: bool,
915 },
916
917 Workspaces,
923
924 Dupes {
926 #[arg(long)]
929 mode: Option<DupesMode>,
930
931 #[arg(long)]
934 min_tokens: Option<usize>,
935
936 #[arg(long)]
939 min_lines: Option<usize>,
940
941 #[arg(long, value_parser = parse_min_occurrences)]
946 min_occurrences: Option<usize>,
947
948 #[arg(long)]
951 threshold: Option<f64>,
952
953 #[arg(long)]
955 skip_local: bool,
956
957 #[arg(long)]
959 cross_language: bool,
960
961 #[arg(long)]
965 ignore_imports: bool,
966
967 #[arg(long, conflicts_with = "ignore_imports")]
970 no_ignore_imports: bool,
971
972 #[arg(long)]
975 top: Option<usize>,
976
977 #[arg(long, value_name = "FILE:LINE")]
979 trace: Option<String>,
980 },
981
982 Health {
988 #[arg(long)]
990 max_cyclomatic: Option<u16>,
991
992 #[arg(long)]
994 max_cognitive: Option<u16>,
995
996 #[arg(long)]
1000 max_crap: Option<f64>,
1001
1002 #[arg(long)]
1004 top: Option<usize>,
1005
1006 #[arg(long, default_value = "cyclomatic")]
1008 sort: SortBy,
1009
1010 #[arg(long)]
1013 complexity: bool,
1014
1015 #[arg(long)]
1022 complexity_breakdown: bool,
1023
1024 #[arg(long)]
1029 file_scores: bool,
1030
1031 #[arg(long)]
1034 coverage_gaps: bool,
1035
1036 #[arg(long)]
1039 hotspots: bool,
1040
1041 #[arg(long)]
1045 ownership: bool,
1046
1047 #[arg(long, value_name = "MODE", value_enum)]
1052 ownership_emails: Option<EmailModeArg>,
1053
1054 #[arg(long)]
1057 targets: bool,
1058
1059 #[arg(long)]
1062 type_coupling: bool,
1063
1064 #[arg(long)]
1069 css: bool,
1070
1071 #[arg(long, value_enum)]
1074 effort: Option<EffortFilter>,
1075
1076 #[arg(long)]
1079 score: bool,
1080
1081 #[arg(long, value_name = "N")]
1090 min_score: Option<f64>,
1091
1092 #[arg(long, value_name = "LEVEL", value_enum)]
1096 min_severity: Option<HealthSeverityCli>,
1097
1098 #[arg(long)]
1102 report_only: bool,
1103
1104 #[arg(long, value_name = "DURATION")]
1107 since: Option<String>,
1108
1109 #[arg(long, value_name = "N")]
1111 min_commits: Option<u32>,
1112
1113 #[expect(
1117 clippy::option_option,
1118 reason = "clap pattern: None=not passed, Some(None)=flag only, Some(Some(path))=with value"
1119 )]
1120 #[arg(long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
1121 save_snapshot: Option<Option<String>>,
1122
1123 #[arg(long)]
1127 trend: bool,
1128
1129 #[arg(long, value_name = "PATH")]
1138 coverage: Option<PathBuf>,
1139
1140 #[arg(long, value_name = "PATH")]
1146 coverage_root: Option<PathBuf>,
1147
1148 #[arg(long, value_name = "PATH")]
1152 runtime_coverage: Option<PathBuf>,
1153
1154 #[arg(long, default_value_t = 100)]
1156 min_invocations_hot: u64,
1157
1158 #[arg(long, value_name = "N")]
1164 min_observation_volume: Option<u32>,
1165
1166 #[arg(long, value_name = "RATIO")]
1171 low_traffic_threshold: Option<f64>,
1172 },
1173
1174 Flags {
1181 #[arg(long)]
1183 top: Option<usize>,
1184 },
1185
1186 Suppressions {
1196 #[arg(long, value_name = "PATH")]
1198 file: Vec<std::path::PathBuf>,
1199 },
1200
1201 Explain {
1207 #[arg(required = true, num_args = 1.., value_name = "ISSUE_TYPE")]
1209 issue_type: Vec<String>,
1210 },
1211
1212 #[command(visible_alias = "review")]
1237 Audit {
1238 #[arg(long = "production-dead-code")]
1240 production_dead_code: bool,
1241
1242 #[arg(long = "production-health")]
1244 production_health: bool,
1245
1246 #[arg(long = "production-dupes")]
1248 production_dupes: bool,
1249
1250 #[arg(long)]
1253 dead_code_baseline: Option<PathBuf>,
1254
1255 #[arg(long)]
1258 health_baseline: Option<PathBuf>,
1259
1260 #[arg(long)]
1263 dupes_baseline: Option<PathBuf>,
1264
1265 #[arg(long)]
1269 max_crap: Option<f64>,
1270
1271 #[arg(long, value_name = "PATH")]
1275 coverage: Option<PathBuf>,
1276
1277 #[arg(long, value_name = "PATH")]
1280 coverage_root: Option<PathBuf>,
1281
1282 #[arg(long = "no-css")]
1284 no_css: bool,
1285
1286 #[arg(long)]
1290 css_deep: bool,
1291
1292 #[arg(long = "no-css-deep")]
1294 no_css_deep: bool,
1295
1296 #[arg(long, value_enum)]
1302 gate: Option<AuditGateArg>,
1303
1304 #[arg(long, value_name = "PATH")]
1313 runtime_coverage: Option<PathBuf>,
1314
1315 #[arg(long, default_value_t = 100)]
1318 min_invocations_hot: u64,
1319
1320 #[arg(long, value_name = "MARKER", hide = true)]
1325 gate_marker: Option<String>,
1326
1327 #[arg(long)]
1333 brief: bool,
1334
1335 #[arg(
1340 long,
1341 value_name = "N",
1342 default_value_t = audit_decision_surface::DEFAULT_DECISION_CAP
1343 )]
1344 max_decisions: usize,
1345
1346 #[arg(long, conflicts_with_all = ["walkthrough_file", "walkthrough"])]
1354 walkthrough_guide: bool,
1355
1356 #[arg(long, value_name = "PATH")]
1364 walkthrough_file: Option<PathBuf>,
1365
1366 #[arg(long, conflicts_with_all = ["walkthrough_guide", "walkthrough_file"])]
1372 walkthrough: bool,
1373
1374 #[arg(long, value_name = "PATH")]
1380 mark_viewed: Vec<PathBuf>,
1381
1382 #[arg(long)]
1386 show_cleared: bool,
1387
1388 #[arg(long)]
1394 show_deprioritized: bool,
1395 },
1396
1397 AuditCache {
1399 #[command(subcommand)]
1400 subcommand: AuditCacheCli,
1401 },
1402
1403 DecisionSurface {
1415 #[arg(
1418 long,
1419 value_name = "N",
1420 default_value_t = audit_decision_surface::DEFAULT_DECISION_CAP
1421 )]
1422 max_decisions: usize,
1423 },
1424
1425 Impact {
1435 #[command(subcommand)]
1436 subcommand: Option<ImpactCli>,
1437 #[arg(long)]
1441 all: bool,
1442 #[arg(long, value_enum, default_value_t = ImpactSortCli::Recent)]
1444 sort: ImpactSortCli,
1445 #[arg(long)]
1448 limit: Option<usize>,
1449 },
1450
1451 Security {
1482 #[command(subcommand)]
1483 subcommand: Option<SecuritySubcommand>,
1484 #[arg(long, value_name = "PATH")]
1489 runtime_coverage: Option<PathBuf>,
1490 #[arg(long, default_value_t = 100)]
1493 min_invocations_hot: u64,
1494 #[arg(long, value_name = "PATH")]
1498 file: Vec<std::path::PathBuf>,
1499 #[arg(long, value_name = "MODE")]
1505 gate: Option<security::SecurityGateArg>,
1506 #[arg(long)]
1508 surface: bool,
1509 },
1510
1511 Report {
1516 #[arg(long, value_name = "PATH")]
1519 from: PathBuf,
1520 },
1521 Schema,
1523
1524 CiTemplate {
1531 #[command(subcommand)]
1532 subcommand: CiTemplateCli,
1533 },
1534
1535 Migrate {
1537 #[arg(long, conflicts_with = "jsonc")]
1539 toml: bool,
1540
1541 #[arg(long)]
1549 jsonc: bool,
1550
1551 #[arg(long)]
1553 dry_run: bool,
1554
1555 #[arg(long, value_name = "PATH")]
1557 from: Option<PathBuf>,
1558 },
1559
1560 License {
1567 #[command(subcommand)]
1568 subcommand: LicenseCli,
1569 },
1570
1571 Telemetry {
1579 #[command(subcommand)]
1580 subcommand: TelemetryCli,
1581 },
1582
1583 Coverage {
1589 #[command(subcommand)]
1590 subcommand: CoverageCli,
1591 },
1592
1593 SetupHooks {
1608 #[arg(long, value_enum)]
1610 agent: Option<setup_hooks::HookAgentArg>,
1611
1612 #[arg(long)]
1614 dry_run: bool,
1615
1616 #[arg(long)]
1619 force: bool,
1620
1621 #[arg(long)]
1623 user: bool,
1624
1625 #[arg(long)]
1627 gitignore_claude: bool,
1628
1629 #[arg(long)]
1633 uninstall: bool,
1634 },
1635
1636 Viz {
1638 #[arg(long = "out", value_name = "PATH")]
1640 output: Option<PathBuf>,
1641
1642 #[arg(long)]
1644 no_open: bool,
1645
1646 #[arg(long = "viz-format", default_value = "html")]
1648 viz_format: viz::VizFormat,
1649 },
1650}
1651
1652#[derive(Subcommand)]
1653enum SecuritySubcommand {
1654 Survivors {
1656 #[arg(long, value_name = "PATH")]
1658 candidates: PathBuf,
1659 #[arg(long, value_name = "PATH")]
1661 verdicts: PathBuf,
1662 #[arg(long)]
1664 require_verdict_for_each_candidate: bool,
1665 },
1666 #[command(name = "blind-spots")]
1668 BlindSpots {
1669 #[arg(long, value_name = "PATH")]
1671 file: Vec<PathBuf>,
1672 },
1673}
1674
1675#[derive(clap::Subcommand)]
1676enum AuditCacheCli {
1677 Remove {
1679 #[arg(long)]
1681 dry_run: bool,
1682
1683 #[arg(long, alias = "force")]
1685 yes: bool,
1686 },
1687}
1688
1689#[derive(clap::Subcommand)]
1690enum LicenseCli {
1691 Activate {
1696 #[arg(value_name = "JWT")]
1698 jwt: Option<String>,
1699
1700 #[arg(long, value_name = "PATH")]
1702 from_file: Option<PathBuf>,
1703
1704 #[arg(long, conflicts_with_all = ["jwt", "from_file"])]
1706 stdin: bool,
1707
1708 #[arg(long, requires = "email")]
1715 trial: bool,
1716
1717 #[arg(long, value_name = "ADDR")]
1719 email: Option<String>,
1720 },
1721 Status,
1723 Refresh,
1725 Deactivate,
1727}
1728
1729#[derive(Clone, Copy, clap::Subcommand)]
1730enum TelemetryCli {
1731 Status,
1733 Enable,
1735 Disable,
1737 Inspect {
1739 #[arg(long)]
1741 example: bool,
1742 },
1743}
1744
1745#[derive(clap::Subcommand)]
1746enum CiTemplateCli {
1747 Gitlab {
1749 #[arg(long, value_name = "DIR", num_args = 0..=1, default_missing_value = ".")]
1753 vendor: Option<PathBuf>,
1754
1755 #[arg(long)]
1757 force: bool,
1758 },
1759}
1760
1761#[derive(clap::Subcommand)]
1762enum CoverageCli {
1763 Setup {
1765 #[arg(short = 'y', long)]
1767 yes: bool,
1768
1769 #[arg(long)]
1771 non_interactive: bool,
1772
1773 #[arg(long)]
1775 json: bool,
1776 },
1777 Analyze {
1783 #[arg(long, value_name = "PATH", conflicts_with = "cloud")]
1785 runtime_coverage: Option<PathBuf>,
1786
1787 #[arg(long, visible_alias = "runtime-coverage-cloud")]
1789 cloud: bool,
1790
1791 #[arg(long, value_name = "KEY")]
1793 api_key: Option<String>,
1794
1795 #[arg(long, value_name = "URL")]
1797 api_endpoint: Option<String>,
1798
1799 #[arg(long, value_name = "OWNER/REPO")]
1805 repo: Option<String>,
1806
1807 #[arg(long, value_name = "ID")]
1809 project_id: Option<String>,
1810
1811 #[arg(long, value_name = "DAYS", default_value_t = 30)]
1813 coverage_period: u16,
1814
1815 #[arg(long, value_name = "ENV")]
1817 environment: Option<String>,
1818
1819 #[arg(long, value_name = "SHA")]
1821 commit_sha: Option<String>,
1822
1823 #[arg(long)]
1825 production: bool,
1826
1827 #[arg(long, default_value_t = 100)]
1829 min_invocations_hot: u64,
1830
1831 #[arg(long, value_name = "N")]
1833 min_observation_volume: Option<u32>,
1834
1835 #[arg(long, value_name = "RATIO")]
1837 low_traffic_threshold: Option<f64>,
1838
1839 #[arg(long)]
1841 top: Option<usize>,
1842
1843 #[arg(long)]
1845 blast_radius: bool,
1846
1847 #[arg(long)]
1849 importance: bool,
1850 },
1851 UploadInventory {
1862 #[arg(long, value_name = "KEY")]
1871 api_key: Option<String>,
1872
1873 #[arg(long, value_name = "URL")]
1878 api_endpoint: Option<String>,
1879
1880 #[arg(long, value_name = "PROJECT_ID")]
1885 project_id: Option<String>,
1886
1887 #[arg(long, value_name = "SHA")]
1892 git_sha: Option<String>,
1893
1894 #[arg(long)]
1900 allow_dirty: bool,
1901
1902 #[arg(long, value_name = "GLOB", num_args = 0..)]
1906 exclude_paths: Vec<String>,
1907
1908 #[arg(long, value_name = "PREFIX")]
1921 path_prefix: Option<String>,
1922
1923 #[arg(long)]
1925 dry_run: bool,
1926
1927 #[arg(long)]
1933 with_callers: bool,
1934
1935 #[arg(long)]
1939 ignore_upload_errors: bool,
1940 },
1941 UploadSourceMaps {
1954 #[arg(long, value_name = "PATH", default_value = "dist")]
1956 dir: PathBuf,
1957
1958 #[arg(long, value_name = "GLOB", default_value = "**/*.map")]
1960 include: String,
1961
1962 #[arg(long, value_name = "GLOB", default_value = "**/node_modules/**")]
1966 exclude: Vec<String>,
1967
1968 #[arg(long, value_name = "NAME")]
1972 repo: Option<String>,
1973
1974 #[arg(long, value_name = "SHA")]
1979 git_sha: Option<String>,
1980
1981 #[arg(long, value_name = "URL")]
1983 endpoint: Option<String>,
1984
1985 #[arg(long, value_name = "BOOL", default_value_t = true, action = clap::ArgAction::Set)]
1990 strip_path: bool,
1991
1992 #[arg(long)]
1994 dry_run: bool,
1995
1996 #[arg(long, value_name = "N", default_value_t = 4)]
1998 concurrency: usize,
1999
2000 #[arg(long)]
2002 fail_fast: bool,
2003 },
2004 UploadStaticFindings {
2011 #[arg(long, value_name = "KEY")]
2021 api_key: Option<String>,
2022
2023 #[arg(long, value_name = "URL")]
2028 api_endpoint: Option<String>,
2029
2030 #[arg(long, value_name = "PROJECT_ID")]
2035 project_id: Option<String>,
2036
2037 #[arg(long, value_name = "SHA")]
2042 git_sha: Option<String>,
2043
2044 #[arg(long)]
2050 allow_dirty: bool,
2051
2052 #[arg(long)]
2054 dry_run: bool,
2055
2056 #[arg(long)]
2060 ignore_upload_errors: bool,
2061 },
2062}
2063
2064#[derive(Subcommand)]
2065enum CiCli {
2066 PlanPrComment {
2068 #[arg(long)]
2070 body: PathBuf,
2071
2072 #[arg(long)]
2074 marker_id: String,
2075
2076 #[arg(long)]
2078 clean: bool,
2079
2080 #[arg(long)]
2082 existing_comment_id: Option<String>,
2083
2084 #[arg(long)]
2086 existing_body: Option<PathBuf>,
2087 },
2088
2089 PostPrComment {
2091 #[arg(long, value_enum)]
2093 provider: CiProviderArg,
2094
2095 #[arg(long)]
2097 pr: Option<String>,
2098
2099 #[arg(long)]
2101 mr: Option<String>,
2102
2103 #[arg(long)]
2105 body: PathBuf,
2106
2107 #[arg(long)]
2109 envelope: Option<PathBuf>,
2110
2111 #[arg(long)]
2113 marker_id: String,
2114
2115 #[arg(long)]
2117 clean: bool,
2118
2119 #[arg(long)]
2121 repo: Option<String>,
2122
2123 #[arg(long = "project-id")]
2125 project_id: Option<String>,
2126
2127 #[arg(long = "api-url")]
2129 api_url: Option<String>,
2130
2131 #[arg(long)]
2133 dry_run: bool,
2134 },
2135
2136 PostReview {
2138 #[arg(long, value_enum)]
2140 provider: CiProviderArg,
2141
2142 #[arg(long)]
2144 pr: Option<String>,
2145
2146 #[arg(long)]
2148 mr: Option<String>,
2149
2150 #[arg(long)]
2152 envelope: PathBuf,
2153
2154 #[arg(long)]
2156 repo: Option<String>,
2157
2158 #[arg(long = "project-id")]
2160 project_id: Option<String>,
2161
2162 #[arg(long = "api-url")]
2164 api_url: Option<String>,
2165
2166 #[arg(long)]
2168 dry_run: bool,
2169 },
2170
2171 PostCheckRun {
2173 #[arg(long, value_enum)]
2175 provider: CiProviderArg,
2176
2177 #[arg(long)]
2179 decision: PathBuf,
2180
2181 #[arg(long)]
2183 repo: String,
2184
2185 #[arg(long = "head-sha")]
2187 head_sha: String,
2188
2189 #[arg(long = "api-url")]
2191 api_url: Option<String>,
2192
2193 #[arg(long = "split-gates")]
2195 split_gates: bool,
2196
2197 #[arg(long)]
2199 dry_run: bool,
2200 },
2201
2202 ReconcileReview {
2204 #[arg(long, value_enum)]
2206 provider: CiProviderArg,
2207
2208 #[arg(long)]
2210 pr: Option<String>,
2211
2212 #[arg(long)]
2214 mr: Option<String>,
2215
2216 #[arg(long)]
2218 envelope: PathBuf,
2219
2220 #[arg(long)]
2222 repo: Option<String>,
2223
2224 #[arg(long = "project-id")]
2226 project_id: Option<String>,
2227
2228 #[arg(long = "api-url")]
2230 api_url: Option<String>,
2231
2232 #[arg(long)]
2234 dry_run: bool,
2235 },
2236}
2237
2238#[derive(Subcommand)]
2239enum RulePackCli {
2240 Init {
2242 name: Option<String>,
2244
2245 #[arg(long, default_value = "starter")]
2247 template: String,
2248
2249 #[arg(long, default_value = "rule-packs")]
2251 dir: String,
2252
2253 #[arg(long)]
2255 no_config: bool,
2256 },
2257
2258 List,
2260
2261 Test {
2263 pack: Option<PathBuf>,
2265 },
2266
2267 Schema,
2269}
2270
2271#[derive(Clone, Copy, Debug, clap::ValueEnum)]
2272enum CiProviderArg {
2273 Github,
2274 Gitlab,
2275}
2276
2277#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2279enum TypeAwareRequireArg {
2280 BestEffort,
2282 Complete,
2284}
2285
2286impl From<TypeAwareRequireArg> for fallow_config::TypeAwareRequire {
2287 fn from(value: TypeAwareRequireArg) -> Self {
2288 match value {
2289 TypeAwareRequireArg::BestEffort => Self::BestEffort,
2290 TypeAwareRequireArg::Complete => Self::Complete,
2291 }
2292 }
2293}
2294
2295#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2297pub enum EffortFilter {
2298 Low,
2299 Medium,
2300 High,
2301}
2302
2303impl EffortFilter {
2304 const fn to_estimate(self) -> fallow_output::EffortEstimate {
2306 match self {
2307 Self::Low => fallow_output::EffortEstimate::Low,
2308 Self::Medium => fallow_output::EffortEstimate::Medium,
2309 Self::High => fallow_output::EffortEstimate::High,
2310 }
2311 }
2312}
2313
2314#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2316pub enum HealthSeverityCli {
2317 Moderate,
2318 High,
2319 Critical,
2320}
2321
2322impl HealthSeverityCli {
2323 const fn to_health_severity(self) -> fallow_output::FindingSeverity {
2325 match self {
2326 Self::Moderate => fallow_output::FindingSeverity::Moderate,
2327 Self::High => fallow_output::FindingSeverity::High,
2328 Self::Critical => fallow_output::FindingSeverity::Critical,
2329 }
2330 }
2331}
2332
2333#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2339pub enum EmailModeArg {
2340 Raw,
2342 Handle,
2344 Anonymized,
2346 #[value(hide = true)]
2348 Hash,
2349}
2350
2351impl EmailModeArg {
2352 const fn to_config(self) -> fallow_config::EmailMode {
2354 match self {
2355 Self::Raw => fallow_config::EmailMode::Raw,
2356 Self::Handle => fallow_config::EmailMode::Handle,
2357 Self::Anonymized => fallow_config::EmailMode::Anonymized,
2358 Self::Hash => fallow_config::EmailMode::Hash,
2359 }
2360 }
2361}
2362
2363#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2365pub enum AuditGateArg {
2366 NewOnly,
2368 All,
2370}
2371
2372impl From<AuditGateArg> for fallow_config::AuditGate {
2373 fn from(value: AuditGateArg) -> Self {
2374 match value {
2375 AuditGateArg::NewOnly => Self::NewOnly,
2376 AuditGateArg::All => Self::All,
2377 }
2378 }
2379}
2380
2381fn parse_min_occurrences(s: &str) -> Result<usize, String> {
2385 let value: usize = s
2386 .parse()
2387 .map_err(|_| format!("`{s}` is not a non-negative integer"))?;
2388 if value < 2 {
2389 return Err(format!(
2390 "must be at least 2 (got {value}); a single occurrence isn't a duplicate"
2391 ));
2392 }
2393 Ok(value)
2394}
2395
2396fn resolve_audit_baseline_path(
2402 root: &std::path::Path,
2403 cli: Option<&std::path::Path>,
2404 config: Option<&str>,
2405) -> Option<PathBuf> {
2406 let path = cli.map(std::path::Path::to_path_buf).or_else(|| {
2407 config.map(|p| {
2408 let path = PathBuf::from(p);
2409 if path_util::is_absolute_path_any_platform(&path) {
2410 path
2411 } else {
2412 root.join(path)
2413 }
2414 })
2415 })?;
2416 if path_util::is_absolute_path_any_platform(&path) {
2417 Some(path)
2418 } else {
2419 Some(root.join(path))
2420 }
2421}
2422
2423fn emit_known_failure(
2424 message: &str,
2425 exit_code: u8,
2426 output: fallow_config::OutputFormat,
2427 reason: telemetry::FailureReason,
2428) -> ExitCode {
2429 telemetry::note_failure_reason(reason);
2430 emit_error(message, exit_code, output)
2431}
2432
2433fn emit_known_failure_with_style(
2434 message: &str,
2435 exit_code: u8,
2436 output: fallow_config::OutputFormat,
2437 json_style: json_style::JsonStyle,
2438 reason: telemetry::FailureReason,
2439) -> ExitCode {
2440 telemetry::note_failure_reason(reason);
2441 error::emit_error_with_style(message, exit_code, output, json_style)
2442}
2443
2444fn unsupported_security_global(cli: &Cli) -> Option<&'static str> {
2445 if cli.baseline.is_some() {
2446 Some("--baseline")
2447 } else if cli.save_baseline.is_some() {
2448 Some("--save-baseline")
2449 } else if cli.production {
2450 Some("--production")
2451 } else if cli.no_production {
2452 Some("--no-production")
2453 } else if cli.group_by.is_some() {
2454 Some("--group-by")
2455 } else if cli.performance {
2456 Some("--performance")
2457 } else if cli.explain_skipped {
2458 Some("--explain-skipped")
2459 } else if cli.fail_on_regression {
2460 Some("--fail-on-regression")
2461 } else if cli.regression_baseline.is_some() {
2462 Some("--regression-baseline")
2463 } else if cli.save_regression_baseline.is_some() {
2464 Some("--save-regression-baseline")
2465 } else if cli.dupes_mode.is_some() {
2466 Some("--dupes-mode")
2467 } else if cli.dupes_threshold.is_some() {
2468 Some("--dupes-threshold")
2469 } else if cli.dupes_min_tokens.is_some() {
2470 Some("--dupes-min-tokens")
2471 } else if cli.dupes_min_lines.is_some() {
2472 Some("--dupes-min-lines")
2473 } else if cli.dupes_min_occurrences.is_some() {
2474 Some("--dupes-min-occurrences")
2475 } else if cli.dupes_skip_local {
2476 Some("--dupes-skip-local")
2477 } else if cli.dupes_cross_language {
2478 Some("--dupes-cross-language")
2479 } else if cli.dupes_ignore_imports {
2480 Some("--dupes-ignore-imports")
2481 } else if cli.dupes_no_ignore_imports {
2482 Some("--dupes-no-ignore-imports")
2483 } else if cli.include_entry_exports {
2484 Some("--include-entry-exports")
2485 } else {
2486 None
2487 }
2488}
2489
2490struct DispatchContext<'a> {
2491 cli: &'a Cli,
2492 root: &'a std::path::Path,
2493 output: fallow_config::OutputFormat,
2494 quiet: bool,
2495 fail_on_issues: bool,
2496 json_style: json_style::JsonStyle,
2497 threads: usize,
2498 tolerance: regression::Tolerance,
2499 save_regression_file: Option<&'a std::path::PathBuf>,
2500 save_to_config: bool,
2501}
2502
2503impl DispatchContext<'_> {
2504 fn production_modes(
2505 &self,
2506 dead_code: bool,
2507 health: bool,
2508 dupes: bool,
2509 ) -> Result<ProductionModes, ExitCode> {
2510 resolve_production_modes(self.cli, self.root, self.output, dead_code, health, dupes)
2511 }
2512
2513 fn production_for(
2514 &self,
2515 analysis: fallow_config::ProductionAnalysis,
2516 ) -> Result<bool, ExitCode> {
2517 self.production_modes(false, false, false)
2518 .map(|modes| modes.for_analysis(analysis))
2519 }
2520
2521 fn regression_opts(&self, scoped: bool) -> regression::RegressionOpts<'_> {
2522 regression::RegressionOpts {
2523 fail_on_regression: self.cli.fail_on_regression,
2524 tolerance: self.tolerance,
2525 regression_baseline_file: self.cli.regression_baseline.as_deref(),
2526 save_target: if let Some(path) = self.save_regression_file {
2527 regression::SaveRegressionTarget::File(path)
2528 } else if self.save_to_config {
2529 regression::SaveRegressionTarget::Config
2530 } else {
2531 regression::SaveRegressionTarget::None
2532 },
2533 scoped,
2534 quiet: self.quiet,
2535 output: self.output,
2536 }
2537 }
2538}
2539
2540#[cfg(unix)]
2555fn signal_test_helper() -> ExitCode {
2556 use std::io::Write as _;
2557 use std::process::Command;
2558
2559 if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER_GRACEFUL").is_some() {
2560 signal::set_graceful_mode();
2561 }
2562
2563 let mut command = Command::new("sleep");
2564 command.arg("30");
2565 let child = match signal::ScopedChild::spawn(&mut command) {
2566 Ok(c) => c,
2567 Err(err) => {
2568 let _ = writeln!(std::io::stderr(), "spawn sleep failed: {err}");
2569 return ExitCode::from(2);
2570 }
2571 };
2572 let pid = child.id();
2573 let stdout = std::io::stdout();
2574 let mut lock = stdout.lock();
2575 let _ = writeln!(lock, "{pid}");
2576 let _ = lock.flush();
2577 drop(lock);
2578 let _ = child.wait_with_output();
2579 if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER_GRACEFUL").is_some() {
2580 return ExitCode::SUCCESS;
2581 }
2582 std::thread::sleep(std::time::Duration::from_secs(5));
2583 ExitCode::SUCCESS
2584}
2585
2586#[cfg(not(unix))]
2587fn signal_test_helper() -> ExitCode {
2588 ExitCode::from(2)
2589}
2590
2591fn install_spawn_hooks() {
2592 fallow_engine::churn::set_spawn_hook(signal::scoped_child::output);
2593 fallow_engine::changed_files::set_spawn_hook(signal::scoped_child::output);
2594}
2595
2596fn install_signal_handlers() {
2597 if let Err(err) = signal::install_handlers() {
2598 use std::io::Write as _;
2599 let stderr = std::io::stderr();
2600 let mut lock = stderr.lock();
2601 let _ = writeln!(lock, "fallow: failed to install signal handlers: {err}");
2602 }
2603}
2604
2605fn redirect_report_to_file(
2610 path: &std::path::Path,
2611 output: fallow_config::OutputFormat,
2612) -> Result<(), ExitCode> {
2613 if let Some(parent) = path.parent()
2614 && !parent.as_os_str().is_empty()
2615 && let Err(e) = std::fs::create_dir_all(parent)
2616 {
2617 return Err(emit_error(
2618 &format!(
2619 "failed to create {} for --output-file: {e}",
2620 parent.display()
2621 ),
2622 2,
2623 output,
2624 ));
2625 }
2626 match std::fs::File::create(path) {
2627 Ok(file) => {
2628 report::sink::set_file_sink(file);
2629 colored::control::set_override(false);
2630 Ok(())
2631 }
2632 Err(e) => Err(emit_error(
2633 &format!("failed to open {} for --output-file: {e}", path.display()),
2634 2,
2635 output,
2636 )),
2637 }
2638}
2639
2640fn finalize_report_file(
2643 path: &std::path::Path,
2644 quiet: bool,
2645 output: fallow_config::OutputFormat,
2646) -> Result<(), ExitCode> {
2647 if let Err(e) = report::sink::flush() {
2648 return Err(emit_error(
2649 &format!("failed to write {}: {e}", path.display()),
2650 2,
2651 output,
2652 ));
2653 }
2654 if !quiet && report::sink::wrote() {
2658 eprintln!("Report written to {}", path.display());
2659 }
2660 Ok(())
2661}
2662
2663pub fn run() -> ExitCode {
2668 install_signal_handlers();
2669 install_spawn_hooks();
2670
2671 if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER").is_some() {
2672 return signal_test_helper();
2673 }
2674
2675 let (mut cli, fmt) = match parse_cli_args() {
2676 Ok(parsed) => parsed,
2677 Err(code) => return code,
2678 };
2679 if cli.pretty && !fmt.payload_is_json {
2680 eprintln!(
2681 "Error: --pretty requires JSON output. Use --format json --pretty, or remove --pretty."
2682 );
2683 return ExitCode::from(2);
2684 }
2685
2686 if let Some(code) = run_schema_command_if_requested(&cli, fmt.json_style) {
2687 return code;
2688 }
2689
2690 if let Some(code) = run_telemetry_command_if_requested(&mut cli, fmt.output, fmt.json_style) {
2691 return code;
2692 }
2693 if is_impact_statusline(&cli) {
2694 let (root, _) = match validate_inputs(&cli, fmt.output, fmt.json_style) {
2695 Ok(validated) => validated,
2696 Err(code) => return code,
2697 };
2698 return cli_impact::render_impact_statusline(&root);
2699 }
2700 let telemetry_run = start_telemetry_run(&cli, &fmt);
2701
2702 let (root, threads) = match validate_inputs(&cli, fmt.output, fmt.json_style) {
2703 Ok(v) => v,
2704 Err(code) => {
2705 return record_run_epilogue(telemetry_run, code, None, cli.parent_run.as_deref());
2706 }
2707 };
2708
2709 let FormatConfig {
2710 output,
2711 payload_is_json: _,
2712 quiet,
2713 fail_on_issues,
2714 json_style,
2715 } = fmt;
2716
2717 let tolerance =
2718 match run_pre_dispatch_checks(&cli, &root, output, json_style, quiet, telemetry_run) {
2719 Ok(tolerance) => tolerance,
2720 Err(code) => return code,
2721 };
2722
2723 let (save_regression_file, save_to_config) = regression_save_targets(&cli);
2724
2725 let command = cli.command.take();
2726 let dispatch = DispatchContext {
2727 cli: &cli,
2728 root: &root,
2729 output,
2730 quiet,
2731 fail_on_issues,
2732 json_style,
2733 threads,
2734 tolerance,
2735 save_regression_file: save_regression_file.as_ref(),
2736 save_to_config,
2737 };
2738 let exit_code = match dispatch_and_finalize(&dispatch, command) {
2739 Ok(code) => code,
2740 Err(code) => return code,
2741 };
2742 record_run_epilogue(telemetry_run, exit_code, None, cli.parent_run.as_deref())
2743}
2744
2745fn is_impact_statusline(cli: &Cli) -> bool {
2748 matches!(
2749 cli.command.as_ref(),
2750 Some(Command::Impact {
2751 subcommand: Some(ImpactCli::Statusline),
2752 all: false,
2753 ..
2754 })
2755 )
2756}
2757
2758fn dispatch_and_finalize(
2762 dispatch: &DispatchContext<'_>,
2763 command: Option<Command>,
2764) -> Result<ExitCode, ExitCode> {
2765 let cli = dispatch.cli;
2766 let output = dispatch.output;
2767 let quiet = dispatch.quiet;
2768
2769 if let Some(path) = cli.output_file.as_deref()
2772 && let Err(code) = redirect_report_to_file(path, output)
2773 {
2774 return Err(code);
2775 }
2776
2777 let exit_code = if command.is_some() && cli_has_bare_coverage_input(cli) {
2778 emit_error(bare_coverage_subcommand_error_message(), 2, output)
2779 } else {
2780 match command {
2781 None => dispatch_bare_command(dispatch),
2782 Some(cmd) => dispatch_subcommand(cmd, dispatch),
2783 }
2784 };
2785
2786 if let Some(path) = cli.output_file.as_deref()
2787 && let Err(code) = finalize_report_file(path, quiet, output)
2788 {
2789 return Err(code);
2790 }
2791 Ok(exit_code)
2792}
2793
2794fn run_telemetry_command_if_requested(
2795 cli: &mut Cli,
2796 output: fallow_config::OutputFormat,
2797 json_style: json_style::JsonStyle,
2798) -> Option<ExitCode> {
2799 if matches!(cli.command, Some(Command::Telemetry { .. }))
2800 && let Some(Command::Telemetry { subcommand }) = cli.command.take()
2801 {
2802 return Some(telemetry::run(
2803 map_telemetry_subcommand(subcommand),
2804 output,
2805 json_style,
2806 ));
2807 }
2808 None
2809}
2810
2811fn run_schema_command_if_requested(
2812 cli: &Cli,
2813 json_style: json_style::JsonStyle,
2814) -> Option<ExitCode> {
2815 match cli.command {
2816 Some(Command::Schema) => Some(schema::run_schema(json_style)),
2817 Some(Command::ConfigSchema) => Some(init::run_config_schema(json_style)),
2818 Some(Command::PluginSchema) => Some(init::run_plugin_schema(json_style)),
2819 Some(Command::RulePackSchema) => Some(init::run_rule_pack_schema(json_style)),
2820 _ => None,
2821 }
2822}
2823
2824fn regression_save_targets(cli: &Cli) -> (Option<std::path::PathBuf>, bool) {
2825 let save_file = cli.save_regression_baseline.as_ref().and_then(|opt| {
2826 opt.as_ref()
2827 .filter(|path| !path.is_empty())
2828 .map(std::path::PathBuf::from)
2829 });
2830 let save_to_config = cli.save_regression_baseline.is_some() && save_file.is_none();
2831 (save_file, save_to_config)
2832}
2833
2834fn dispatch_bare_command(dispatch: &DispatchContext<'_>) -> ExitCode {
2835 let cli = dispatch.cli;
2836 let (run_check, run_dupes, run_health) = combined::resolve_analyses(&cli.only, &cli.skip);
2837 let production = match dispatch.production_modes(
2838 cli.production_dead_code,
2839 cli.production_health,
2840 cli.production_dupes,
2841 ) {
2842 Ok(production) => production,
2843 Err(code) => return code,
2844 };
2845 let coverage_inputs = match resolve_health_coverage_inputs(
2846 dispatch,
2847 cli.coverage.as_deref(),
2848 cli.coverage_root.as_deref(),
2849 ) {
2850 Ok(inputs) => inputs,
2851 Err(code) => return code,
2852 };
2853 run_bare_combined(
2854 dispatch,
2855 production,
2856 &coverage_inputs,
2857 BareAnalyses {
2858 run_check,
2859 run_dupes,
2860 run_health,
2861 },
2862 )
2863}
2864
2865#[derive(Clone, Copy)]
2867struct BareAnalyses {
2868 run_check: bool,
2869 run_dupes: bool,
2870 run_health: bool,
2871}
2872
2873fn run_bare_combined(
2876 dispatch: &DispatchContext<'_>,
2877 production: ProductionModes,
2878 coverage_inputs: &ResolvedHealthCoverageInputs,
2879 analyses: BareAnalyses,
2880) -> ExitCode {
2881 let cli = dispatch.cli;
2882 let (output, quiet, fail_on_issues) =
2883 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
2884 combined::run_combined(&combined::CombinedOptions {
2885 root: dispatch.root,
2886 config_path: &cli.config,
2887 output,
2888 json_style: dispatch.json_style,
2889 no_cache: cli.no_cache,
2890 threads: dispatch.threads,
2891 quiet,
2892 allow_remote_extends: cli.allow_remote_extends,
2893 fail_on_issues,
2894 sarif_file: cli.sarif_file.as_deref(),
2895 changed_since: cli.changed_since.as_deref(),
2896 churn_file: cli.churn_file.as_deref(),
2897 baseline: cli.baseline.as_deref(),
2898 save_baseline: cli.save_baseline.as_deref(),
2899 production: cli.production,
2900 production_dead_code: Some(production.dead_code),
2901 production_health: Some(production.health),
2902 production_dupes: Some(production.dupes),
2903 workspace: cli.workspace.as_deref(),
2904 changed_workspaces: cli.changed_workspaces.as_deref(),
2905 group_by: cli.group_by,
2906 type_aware: cli.type_aware,
2907 type_aware_projects: &cli.type_aware_project,
2908 type_aware_require: cli.type_aware_require.map(Into::into),
2909 explain: cli.explain,
2910 explain_skipped: cli.explain_skipped,
2911 performance: cli.performance,
2912 summary: cli.summary,
2913 run_check: analyses.run_check,
2914 run_dupes: analyses.run_dupes,
2915 run_health: analyses.run_health,
2916 dupes_mode: cli.dupes_mode,
2917 dupes_threshold: cli.dupes_threshold,
2918 dupes_min_tokens: cli.dupes_min_tokens,
2919 dupes_min_lines: cli.dupes_min_lines,
2920 dupes_min_occurrences: cli.dupes_min_occurrences,
2921 dupes_skip_local: cli.dupes_skip_local,
2922 dupes_cross_language: cli.dupes_cross_language,
2923 dupes_ignore_imports: resolve_ignore_imports(
2924 cli.dupes_ignore_imports,
2925 cli.dupes_no_ignore_imports,
2926 ),
2927 score: cli.score || cli.trend,
2928 trend: cli.trend,
2929 save_snapshot: cli.save_snapshot.as_ref(),
2930 coverage: coverage_inputs.coverage.as_deref(),
2931 coverage_root: coverage_inputs.coverage_root.as_deref(),
2932 include_entry_exports: cli.include_entry_exports,
2933 regression_opts: dispatch.regression_opts(
2934 cli.changed_since.is_some()
2935 || cli.workspace.is_some()
2936 || cli.changed_workspaces.is_some(),
2937 ),
2938 })
2939}
2940
2941fn dispatch_subcommand(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
2942 let cli = dispatch.cli;
2943 let root = dispatch.root;
2944 let output = dispatch.output;
2945 let quiet = dispatch.quiet;
2946 match command {
2947 check @ Command::Check { .. } => dispatch_check_command(check, dispatch),
2948 Command::Watch { no_clear } => dispatch_watch(dispatch, no_clear),
2949 Command::TypeAware { subcommand } => dispatch_type_aware_command(dispatch, subcommand),
2950 Command::Inspect {
2951 file,
2952 symbol,
2953 symbol_chain,
2954 churn,
2955 } => dispatch_inspect_command(dispatch, file, symbol, symbol_chain, churn),
2956 Command::Trace {
2957 symbol,
2958 callers,
2959 callees,
2960 depth,
2961 } => dispatch_trace_command(dispatch, symbol, callers, callees, depth),
2962 fix @ Command::Fix { .. } => dispatch_fix_command(&fix, dispatch),
2963 init @ Command::Init { .. } => dispatch_init_command(init, root, quiet),
2964 Command::Hooks { subcommand } => {
2965 run_hooks_command(root, subcommand, output, dispatch.json_style)
2966 }
2967 Command::Ci { subcommand } => {
2968 ci::run(map_ci_subcommand(subcommand), output, dispatch.json_style)
2969 }
2970 Command::ConfigSchema => init::run_config_schema(dispatch.json_style),
2971 Command::PluginSchema => init::run_plugin_schema(dispatch.json_style),
2972 Command::PluginCheck => plugin_check::run_plugin_check(root, output, dispatch.json_style),
2973 Command::RulePackSchema => init::run_rule_pack_schema(dispatch.json_style),
2974 Command::RulePack { subcommand } => dispatch_rule_pack_command(dispatch, subcommand),
2975 Command::Guard { files } => dispatch_guard_command(dispatch, &files),
2976 Command::CiTemplate { subcommand } => dispatch_ci_template_command(subcommand),
2977 Command::Config { path } => config::run_config_with_options(config::RunConfigInput {
2978 root,
2979 explicit_config: cli.config.as_deref(),
2980 path_only: path,
2981 output,
2982 quiet,
2983 json_style: dispatch.json_style,
2984 load_options: fallow_config::ConfigLoadOptions {
2985 allow_remote_extends: cli.allow_remote_extends,
2986 },
2987 }),
2988 Command::Recommend => onboarding::run_recommend(root, output, dispatch.json_style),
2989 list @ (Command::Workspaces | Command::List { .. }) => {
2990 dispatch_list_command(&list, dispatch)
2991 }
2992 dupes @ Command::Dupes { .. } => dispatch_dupes_command(dupes, dispatch),
2993 health @ Command::Health { .. } => dispatch_health_command(health, dispatch),
2994 Command::Flags { top } => dispatch_flags_command(dispatch, top),
2995 Command::Suppressions { file } => dispatch_suppressions_command(dispatch, &file),
2996 Command::Explain { issue_type } => {
2997 explain::run_explain(&issue_type.join(" "), output, dispatch.json_style)
2998 }
2999 audit @ Command::Audit { .. } => dispatch_audit_command(audit, dispatch),
3000 Command::AuditCache { subcommand } => dispatch_audit_cache_command(dispatch, &subcommand),
3001 Command::DecisionSurface { max_decisions } => {
3002 dispatch_decision_surface(dispatch, max_decisions)
3003 }
3004 Command::Impact {
3005 subcommand,
3006 all,
3007 sort,
3008 limit,
3009 } => dispatch_impact(
3010 root,
3011 quiet,
3012 output,
3013 dispatch.json_style,
3014 subcommand,
3015 ImpactCrossRepoOpts { all, sort, limit },
3016 ),
3017 security @ Command::Security { .. } => dispatch_security_command(security, dispatch),
3018 Command::Viz {
3019 output: viz_output,
3020 no_open,
3021 viz_format,
3022 } => dispatch_viz(dispatch, viz_output.as_deref(), no_open, viz_format),
3023 Command::Report { from } => {
3024 cli_report::run_report(&from, output, root, cli.config.as_deref())
3025 }
3026 Command::Schema => unreachable!("handled above"),
3027 migrate @ Command::Migrate { .. } => dispatch_migrate_command(migrate, root),
3028 Command::License { subcommand } => {
3029 dispatch_license_command(subcommand, output, dispatch.json_style)
3030 }
3031 Command::Telemetry { .. } => unreachable!("handled before root validation"),
3032 Command::Coverage { subcommand } => dispatch_coverage_command(dispatch, &subcommand),
3033 setup_hooks @ Command::SetupHooks { .. } => {
3034 dispatch_setup_hooks_command(&setup_hooks, dispatch)
3035 }
3036 }
3037}
3038
3039fn dispatch_type_aware_command(
3040 dispatch: &DispatchContext<'_>,
3041 subcommand: TypeAwareCli,
3042) -> ExitCode {
3043 match subcommand {
3044 TypeAwareCli::Status => {
3045 let status = fallow_api::type_aware_status(dispatch.root);
3046 match dispatch.output {
3047 fallow_config::OutputFormat::Json => {
3048 let output = type_aware_status_output(dispatch.root, status);
3049 match fallow_output::serialize_type_aware_status_json_output(
3050 output,
3051 crate::output_runtime::current_root_envelope_mode(),
3052 ) {
3053 Ok(value) => match dispatch.json_style.serialize(&value) {
3054 Ok(json) => {
3055 crate::report::sink::outln!("{json}");
3056 ExitCode::SUCCESS
3057 }
3058 Err(error) => emit_error(
3059 &format!("failed to serialize type-aware status: {error}"),
3060 2,
3061 dispatch.output,
3062 ),
3063 },
3064 Err(error) => emit_error(
3065 &format!("failed to build type-aware status: {error}"),
3066 2,
3067 dispatch.output,
3068 ),
3069 }
3070 }
3071 fallow_config::OutputFormat::Human => {
3072 if status.available {
3073 crate::report::sink::outln!(
3074 "{}",
3075 report::human_status_line(
3076 report::HumanStatus::Ok,
3077 format_args!(
3078 "Type-aware companion: available ({}, protocol {}, TypeScript {})",
3079 status.package_version.as_deref().unwrap_or("unknown"),
3080 status.protocol_version,
3081 status.backend_version.as_deref().unwrap_or("unknown"),
3082 )
3083 )
3084 );
3085 } else {
3086 crate::report::sink::outln!(
3087 "{}",
3088 report::human_status_line(
3089 report::HumanStatus::Inactive,
3090 "Type-aware companion: unavailable"
3091 )
3092 );
3093 if let Some(remediation) = status.remediation {
3094 crate::report::sink::outln!(
3095 "{}",
3096 report::human_status_line(
3097 report::HumanStatus::Warning,
3098 format_args!("Action: {remediation}")
3099 )
3100 );
3101 }
3102 }
3103 ExitCode::SUCCESS
3104 }
3105 _ => emit_error(
3106 "type-aware status supports human and json output",
3107 2,
3108 dispatch.output,
3109 ),
3110 }
3111 }
3112 }
3113}
3114
3115fn type_aware_status_output(
3116 root: &Path,
3117 status: fallow_api::TypeAwareStatus,
3118) -> fallow_output::TypeAwareStatusOutput {
3119 let companion_path = status.companion_path.as_deref().map(|path| {
3120 if let Ok(relative) = path.strip_prefix(root)
3121 && !relative.as_os_str().is_empty()
3122 {
3123 relative.to_string_lossy().replace('\\', "/")
3124 } else {
3125 path.file_name()
3126 .unwrap_or(path.as_os_str())
3127 .to_string_lossy()
3128 .into_owned()
3129 }
3130 });
3131 let remediation = status.remediation.map(|message| {
3132 let without_root = message.replace(root.to_string_lossy().as_ref(), ".");
3133 status.companion_path.as_deref().map_or_else(
3134 || without_root.clone(),
3135 |path| {
3136 without_root.replace(
3137 path.to_string_lossy().as_ref(),
3138 companion_path.as_deref().unwrap_or("fallow-type-aware"),
3139 )
3140 },
3141 )
3142 });
3143 fallow_output::TypeAwareStatusOutput {
3144 schema_version: fallow_types::envelope::SchemaVersion(report::SCHEMA_VERSION),
3145 version: fallow_types::envelope::ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
3146 available: status.available,
3147 discovery_source: status.discovery_source.map(str::to_string),
3148 companion_path,
3149 package_version: status.package_version,
3150 protocol_version: status.protocol_version,
3151 backend_family: status.backend_family,
3152 backend_version: status.backend_version,
3153 remediation,
3154 }
3155}
3156
3157fn dispatch_check_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3159 let filters = check_issue_filters(&command);
3160 let Command::Check {
3161 include_dupes,
3162 trace,
3163 trace_file,
3164 trace_dependency,
3165 impact_closure,
3166 symbol_impact,
3167 top,
3168 file,
3169 ..
3170 } = command
3171 else {
3172 unreachable!("check dispatcher only handles check commands");
3173 };
3174
3175 dispatch_check(
3176 dispatch,
3177 &CheckDispatchArgs {
3178 filters,
3179 trace_opts: TraceOptions {
3180 trace_export: trace,
3181 trace_file,
3182 trace_dependency,
3183 impact_closure,
3184 symbol_impact,
3185 performance: dispatch.cli.performance,
3186 },
3187 include_dupes,
3188 type_aware: dispatch.cli.type_aware,
3189 type_aware_project: dispatch.cli.type_aware_project.clone(),
3190 type_aware_require: dispatch.cli.type_aware_require,
3191 top,
3192 file,
3193 },
3194 )
3195}
3196
3197fn check_issue_filters(command: &Command) -> IssueFilters {
3202 check_issue_filters_framework(command, &check_issue_filters_core(command))
3203}
3204
3205fn check_issue_filters_core(command: &Command) -> IssueFilters {
3208 let Command::Check {
3209 unused_files,
3210 unused_exports,
3211 unused_deps,
3212 unused_types,
3213 private_type_leaks,
3214 unused_enum_members,
3215 unused_class_members,
3216 unresolved_imports,
3217 unlisted_deps,
3218 duplicate_exports,
3219 circular_deps,
3220 re_export_cycles,
3221 boundary_violations,
3222 policy_violations,
3223 stale_suppressions,
3224 ..
3225 } = command
3226 else {
3227 unreachable!("check filter builder only handles check commands");
3228 };
3229
3230 let mut filters = IssueFilters::default();
3231 for (flag, active) in [
3232 ("--unused-files", *unused_files),
3233 ("--unused-exports", *unused_exports),
3234 ("--unused-deps", *unused_deps),
3235 ("--unused-types", *unused_types),
3236 ("--private-type-leaks", *private_type_leaks),
3237 ("--unused-enum-members", *unused_enum_members),
3238 ("--unused-class-members", *unused_class_members),
3239 ("--unresolved-imports", *unresolved_imports),
3240 ("--unlisted-deps", *unlisted_deps),
3241 ("--duplicate-exports", *duplicate_exports),
3242 ("--circular-deps", *circular_deps),
3243 ("--re-export-cycles", *re_export_cycles),
3244 ("--boundary-violations", *boundary_violations),
3245 ("--policy-violations", *policy_violations),
3246 ("--stale-suppressions", *stale_suppressions),
3247 ] {
3248 enable_check_filter(&mut filters, flag, active);
3249 }
3250 filters
3251}
3252
3253fn check_issue_filters_framework(command: &Command, base: &IssueFilters) -> IssueFilters {
3256 let Command::Check {
3257 unused_store_members,
3258 unprovided_injects,
3259 unrendered_components,
3260 unused_component_props,
3261 unused_component_emits,
3262 unused_component_inputs,
3263 unused_component_outputs,
3264 unused_svelte_events,
3265 unused_server_actions,
3266 unused_load_data_keys,
3267 unused_catalog_entries,
3268 empty_catalog_groups,
3269 unresolved_catalog_references,
3270 unused_dependency_overrides,
3271 misconfigured_dependency_overrides,
3272 ..
3273 } = command
3274 else {
3275 unreachable!("check filter builder only handles check commands");
3276 };
3277
3278 let mut filters = base.clone();
3279 for (flag, active) in [
3280 ("--unused-store-members", *unused_store_members),
3281 ("--unprovided-injects", *unprovided_injects),
3282 ("--unrendered-components", *unrendered_components),
3283 ("--unused-component-props", *unused_component_props),
3284 ("--unused-component-emits", *unused_component_emits),
3285 ("--unused-component-inputs", *unused_component_inputs),
3286 ("--unused-component-outputs", *unused_component_outputs),
3287 ("--unused-svelte-events", *unused_svelte_events),
3288 ("--unused-server-actions", *unused_server_actions),
3289 ("--unused-load-data-keys", *unused_load_data_keys),
3290 ("--unused-catalog-entries", *unused_catalog_entries),
3291 ("--empty-catalog-groups", *empty_catalog_groups),
3292 (
3293 "--unresolved-catalog-references",
3294 *unresolved_catalog_references,
3295 ),
3296 (
3297 "--unused-dependency-overrides",
3298 *unused_dependency_overrides,
3299 ),
3300 (
3301 "--misconfigured-dependency-overrides",
3302 *misconfigured_dependency_overrides,
3303 ),
3304 ] {
3305 enable_check_filter(&mut filters, flag, active);
3306 }
3307 filters
3308}
3309
3310fn enable_check_filter(filters: &mut IssueFilters, flag: &str, active: bool) {
3311 if active {
3312 assert!(
3313 filters.enable_cli_filter_flag(flag),
3314 "check command uses unregistered dead-code filter flag {flag}"
3315 );
3316 }
3317}
3318
3319fn dispatch_inspect_command(
3320 dispatch: &DispatchContext<'_>,
3321 file: Option<String>,
3322 symbol: Option<String>,
3323 symbol_chain: bool,
3324 churn: bool,
3325) -> ExitCode {
3326 let target = match (file, symbol) {
3327 (Some(file), None) => inspect::InspectTarget::File { file },
3328 (None, Some(symbol)) => match symbol.rsplit_once(':') {
3329 Some((file, export_name))
3330 if !file.trim().is_empty() && !export_name.trim().is_empty() =>
3331 {
3332 inspect::InspectTarget::Symbol {
3333 file: file.to_string(),
3334 export_name: export_name.to_string(),
3335 }
3336 }
3337 _ => {
3338 return emit_error(
3339 "--symbol must be formatted as FILE:EXPORT",
3340 2,
3341 dispatch.output,
3342 );
3343 }
3344 },
3345 _ => {
3346 return emit_error(
3347 "inspect requires exactly one of --file or --symbol",
3348 2,
3349 dispatch.output,
3350 );
3351 }
3352 };
3353
3354 let churn_config = if churn {
3355 match load_config_for_analysis(
3356 dispatch.root,
3357 &dispatch.cli.config,
3358 ConfigLoadOptions {
3359 output: dispatch.output,
3360 no_cache: dispatch.cli.no_cache,
3361 threads: dispatch.threads,
3362 production_override: None,
3363 quiet: dispatch.quiet,
3364 allow_remote_extends: dispatch.cli.allow_remote_extends,
3365 },
3366 fallow_config::ProductionAnalysis::Health,
3367 ) {
3368 Ok(config) => Some(config),
3369 Err(code) => return code,
3370 }
3371 } else {
3372 None
3373 };
3374
3375 inspect::run_inspect(&inspect::InspectOptions {
3376 root: dispatch.root,
3377 config_path: dispatch.cli.config.as_ref(),
3378 output: dispatch.output,
3379 json_style: dispatch.json_style,
3380 no_cache: dispatch.cli.no_cache,
3381 no_production: dispatch.cli.no_production,
3382 max_file_size: dispatch.cli.max_file_size,
3383 threads: dispatch.threads,
3384 quiet: dispatch.quiet,
3385 production: dispatch.cli.production,
3386 workspace: dispatch.cli.workspace.as_ref(),
3387 target,
3388 churn_cache_dir: churn_config
3389 .as_ref()
3390 .map(|config| config.cache_dir.as_path()),
3391 symbol_chain,
3392 type_aware: dispatch.cli.type_aware,
3393 type_aware_projects: &dispatch.cli.type_aware_project,
3394 type_aware_require: dispatch.cli.type_aware_require.map(Into::into),
3395 })
3396}
3397
3398fn dispatch_trace_command(
3399 dispatch: &DispatchContext<'_>,
3400 symbol: String,
3401 callers: bool,
3402 callees: bool,
3403 depth: Option<u32>,
3404) -> ExitCode {
3405 trace_chain::run_trace(&trace_chain::TraceChainOptions {
3406 root: dispatch.root,
3407 config_path: &dispatch.cli.config,
3408 output: dispatch.output,
3409 json_style: dispatch.json_style,
3410 no_cache: dispatch.cli.no_cache,
3411 threads: dispatch.threads,
3412 quiet: dispatch.quiet,
3413 allow_remote_extends: dispatch.cli.allow_remote_extends,
3414 target: symbol,
3415 callers,
3416 callees,
3417 depth: depth.unwrap_or(fallow_types::trace_chain::DEFAULT_TRACE_DEPTH),
3418 })
3419}
3420
3421fn dispatch_security_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3422 let Command::Security {
3423 subcommand,
3424 runtime_coverage,
3425 min_invocations_hot,
3426 file,
3427 gate,
3428 surface,
3429 } = command
3430 else {
3431 unreachable!("security dispatcher only handles security commands");
3432 };
3433
3434 let gate = gate.map(security::SecurityGateArg::into_mode);
3435 let cli = dispatch.cli;
3436 let (output, _quiet, fail_on_issues) =
3437 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
3438 let derived_flags = SecurityDerivedFlagState {
3439 output,
3440 json_style: dispatch.json_style,
3441 ci: cli.ci,
3442 fail_on_issues,
3443 sarif_file: cli.sarif_file.as_deref(),
3444 summary: cli.summary,
3445 explain: cli.explain,
3446 runtime_coverage: runtime_coverage.as_deref(),
3447 min_invocations_hot,
3448 file: file.as_slice(),
3449 gate,
3450 surface,
3451 };
3452 if let Some(code) = try_run_security_survivors(subcommand.as_ref(), &derived_flags) {
3453 return code;
3454 }
3455
3456 let scoped_files = scoped_security_files(&file, subcommand.as_ref());
3457 run_security_blind_spots_or_default(
3458 dispatch,
3459 &SecurityRunInputs {
3460 scoped_files: &scoped_files,
3461 subcommand: &subcommand,
3462 runtime_coverage: runtime_coverage.as_deref(),
3463 min_invocations_hot,
3464 gate,
3465 surface,
3466 },
3467 &derived_flags,
3468 )
3469}
3470
3471struct SecurityRunInputs<'a> {
3474 scoped_files: &'a [PathBuf],
3475 subcommand: &'a Option<SecuritySubcommand>,
3476 runtime_coverage: Option<&'a Path>,
3477 min_invocations_hot: u64,
3478 gate: Option<security::SecurityGateMode>,
3479 surface: bool,
3480}
3481
3482fn run_security_blind_spots_or_default(
3484 dispatch: &DispatchContext<'_>,
3485 inputs: &SecurityRunInputs<'_>,
3486 derived_flags: &SecurityDerivedFlagState<'_>,
3487) -> ExitCode {
3488 let cli = dispatch.cli;
3489 let (output, quiet, fail_on_issues) =
3490 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
3491 let opts = security::SecurityOptions {
3492 root: dispatch.root,
3493 config_path: &cli.config,
3494 output,
3495 json_style: dispatch.json_style,
3496 no_cache: cli.no_cache,
3497 threads: dispatch.threads,
3498 quiet,
3499 allow_remote_extends: cli.allow_remote_extends,
3500 fail_on_issues,
3501 sarif_file: cli.sarif_file.as_deref(),
3502 summary: cli.summary,
3503 changed_since: cli.changed_since.as_deref(),
3504 use_shared_diff_index: true,
3505 workspace: cli.workspace.as_deref(),
3506 changed_workspaces: cli.changed_workspaces.as_deref(),
3507 file: inputs.scoped_files,
3508 surface: inputs.surface,
3509 gate: inputs.gate,
3510 runtime_coverage: inputs.runtime_coverage,
3511 min_invocations_hot: inputs.min_invocations_hot,
3512 explain: cli.explain,
3513 };
3514 if matches!(
3515 inputs.subcommand,
3516 Some(SecuritySubcommand::BlindSpots { .. })
3517 ) {
3518 if let Some(code) = validate_security_blind_spots_flags(derived_flags) {
3519 return code;
3520 }
3521 security::run_blind_spots(&opts)
3522 } else {
3523 security::run(&opts)
3524 }
3525}
3526
3527fn try_run_security_survivors(
3530 subcommand: Option<&SecuritySubcommand>,
3531 flags: &SecurityDerivedFlagState<'_>,
3532) -> Option<ExitCode> {
3533 let Some(SecuritySubcommand::Survivors {
3534 candidates,
3535 verdicts,
3536 require_verdict_for_each_candidate,
3537 }) = subcommand
3538 else {
3539 return None;
3540 };
3541 if let Some(code) = validate_security_survivors_flags(flags) {
3542 return Some(code);
3543 }
3544 Some(security::run_survivors(
3545 &security::SecuritySurvivorsOptions {
3546 output: flags.output,
3547 json_style: flags.json_style,
3548 candidates,
3549 verdicts,
3550 require_verdict_for_each_candidate: *require_verdict_for_each_candidate,
3551 },
3552 ))
3553}
3554
3555fn scoped_security_files(
3557 file: &[PathBuf],
3558 subcommand: Option<&SecuritySubcommand>,
3559) -> Vec<PathBuf> {
3560 let mut scoped_files = file.to_vec();
3561 if let Some(SecuritySubcommand::BlindSpots {
3562 file: blind_spot_files,
3563 }) = subcommand
3564 {
3565 scoped_files.extend(blind_spot_files.iter().cloned());
3566 }
3567 scoped_files
3568}
3569
3570struct SecurityDerivedFlagState<'a> {
3571 output: fallow_config::OutputFormat,
3572 json_style: json_style::JsonStyle,
3573 ci: bool,
3574 fail_on_issues: bool,
3575 sarif_file: Option<&'a Path>,
3576 summary: bool,
3577 explain: bool,
3578 runtime_coverage: Option<&'a Path>,
3579 min_invocations_hot: u64,
3580 file: &'a [PathBuf],
3581 gate: Option<security::SecurityGateMode>,
3582 surface: bool,
3583}
3584
3585fn validate_security_survivors_flags(flags: &SecurityDerivedFlagState<'_>) -> Option<ExitCode> {
3586 let flag = if flags.ci {
3587 Some("--ci")
3588 } else if flags.fail_on_issues {
3589 Some("--fail-on-issues")
3590 } else if flags.sarif_file.is_some() {
3591 Some("--sarif-file")
3592 } else if flags.summary {
3593 Some("--summary")
3594 } else if flags.explain {
3595 Some("--explain")
3596 } else if flags.runtime_coverage.is_some() {
3597 Some("--runtime-coverage")
3598 } else if flags.min_invocations_hot != DEFAULT_MIN_INVOCATIONS_HOT {
3599 Some("--min-invocations-hot")
3600 } else if !flags.file.is_empty() {
3601 Some("--file")
3602 } else if flags.gate.is_some() {
3603 Some("--gate")
3604 } else if flags.surface {
3605 Some("--surface")
3606 } else {
3607 None
3608 }?;
3609 Some(emit_error(
3610 &format!("{flag} is not valid with `fallow security survivors`."),
3611 2,
3612 flags.output,
3613 ))
3614}
3615
3616fn validate_security_blind_spots_flags(flags: &SecurityDerivedFlagState<'_>) -> Option<ExitCode> {
3617 let flag = if flags.ci {
3618 Some("--ci")
3619 } else if flags.fail_on_issues {
3620 Some("--fail-on-issues")
3621 } else if flags.sarif_file.is_some() {
3622 Some("--sarif-file")
3623 } else if flags.summary {
3624 Some("--summary")
3625 } else if flags.explain {
3626 Some("--explain")
3627 } else if flags.runtime_coverage.is_some() {
3628 Some("--runtime-coverage")
3629 } else if flags.min_invocations_hot != DEFAULT_MIN_INVOCATIONS_HOT {
3630 Some("--min-invocations-hot")
3631 } else if flags.gate.is_some() {
3632 Some("--gate")
3633 } else if flags.surface {
3634 Some("--surface")
3635 } else {
3636 None
3637 }?;
3638 Some(emit_error(
3639 &format!("{flag} is not valid with `fallow security blind-spots`."),
3640 2,
3641 flags.output,
3642 ))
3643}
3644
3645fn dispatch_dupes_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3646 let Command::Dupes {
3647 mode,
3648 min_tokens,
3649 min_lines,
3650 min_occurrences,
3651 threshold,
3652 skip_local,
3653 cross_language,
3654 ignore_imports,
3655 no_ignore_imports,
3656 top,
3657 trace,
3658 } = command
3659 else {
3660 unreachable!("dupes dispatcher only handles dupes commands");
3661 };
3662
3663 dispatch_dupes(
3664 dispatch,
3665 &DupesDispatchArgs {
3666 mode,
3667 min_tokens,
3668 min_lines,
3669 min_occurrences,
3670 threshold,
3671 skip_local,
3672 cross_language,
3673 ignore_imports,
3674 no_ignore_imports,
3675 top,
3676 trace,
3677 },
3678 )
3679}
3680
3681fn dispatch_init_command(command: Command, root: &Path, quiet: bool) -> ExitCode {
3682 let Command::Init {
3683 toml,
3684 agents,
3685 hooks,
3686 branch,
3687 decline,
3688 } = command
3689 else {
3690 unreachable!("init dispatcher only handles init commands");
3691 };
3692
3693 init::run_init(&init::InitOptions {
3694 root,
3695 use_toml: toml,
3696 agents,
3697 hooks,
3698 branch: branch.as_deref(),
3699 decline,
3700 quiet,
3701 })
3702}
3703
3704fn dispatch_fix_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3705 let Command::Fix {
3706 dry_run,
3707 yes,
3708 no_create_config,
3709 } = command
3710 else {
3711 unreachable!("fix dispatcher only handles fix commands");
3712 };
3713
3714 dispatch_fix(
3715 dispatch,
3716 FixDispatchArgs {
3717 dry_run: *dry_run,
3718 yes: *yes,
3719 no_create_config: *no_create_config,
3720 },
3721 )
3722}
3723
3724fn dispatch_list_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3725 match command {
3726 Command::Workspaces => dispatch_list(dispatch, ListDispatchArgs::workspaces()),
3727 Command::List {
3728 entry_points,
3729 files,
3730 plugins,
3731 boundaries,
3732 workspaces,
3733 } => dispatch_list(
3734 dispatch,
3735 ListDispatchArgs {
3736 entry_points: *entry_points,
3737 files: *files,
3738 plugins: *plugins,
3739 boundaries: *boundaries,
3740 workspaces: *workspaces,
3741 },
3742 ),
3743 _ => unreachable!("list dispatcher only handles list commands"),
3744 }
3745}
3746
3747fn dispatch_migrate_command(command: Command, root: &Path) -> ExitCode {
3748 let Command::Migrate {
3749 toml,
3750 jsonc,
3751 dry_run,
3752 from,
3753 } = command
3754 else {
3755 unreachable!("migrate dispatcher only handles migrate commands");
3756 };
3757
3758 migrate::run_migrate(root, toml, jsonc, dry_run, from.as_deref())
3759}
3760
3761fn dispatch_license_command(
3762 subcommand: LicenseCli,
3763 output: fallow_config::OutputFormat,
3764 json_style: json_style::JsonStyle,
3765) -> ExitCode {
3766 license::run(&map_license_subcommand(subcommand), output, json_style)
3767}
3768
3769fn dispatch_ci_template_command(subcommand: CiTemplateCli) -> ExitCode {
3770 match subcommand {
3771 CiTemplateCli::Gitlab { vendor, force } => {
3772 ci_template::run_gitlab_template(&ci_template::GitlabTemplateOptions {
3773 vendor_dir: vendor,
3774 force,
3775 })
3776 }
3777 }
3778}
3779
3780fn dispatch_coverage_command(dispatch: &DispatchContext<'_>, subcommand: &CoverageCli) -> ExitCode {
3781 let cli = dispatch.cli;
3782 coverage::run(
3783 map_coverage_subcommand(subcommand, cli.explain),
3784 &coverage::RunContext {
3785 root: dispatch.root,
3786 config_path: &cli.config,
3787 output: dispatch.output,
3788 json_style: dispatch.json_style,
3789 quiet: dispatch.quiet,
3790 no_cache: cli.no_cache,
3791 threads: dispatch.threads,
3792 explain: cli.explain,
3793 allow_remote_extends: cli.allow_remote_extends,
3794 },
3795 )
3796}
3797
3798fn dispatch_health_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3799 let Command::Health {
3800 max_cyclomatic,
3801 max_cognitive,
3802 max_crap,
3803 top,
3804 sort,
3805 complexity,
3806 complexity_breakdown,
3807 file_scores,
3808 coverage_gaps,
3809 hotspots,
3810 ownership,
3811 ownership_emails,
3812 targets,
3813 type_coupling,
3814 css,
3815 effort,
3816 score,
3817 min_score,
3818 min_severity,
3819 report_only,
3820 since,
3821 min_commits,
3822 save_snapshot,
3823 trend,
3824 coverage,
3825 coverage_root,
3826 runtime_coverage,
3827 min_invocations_hot,
3828 min_observation_volume,
3829 low_traffic_threshold,
3830 } = command
3831 else {
3832 unreachable!("health dispatcher only handles health commands");
3833 };
3834
3835 let ownership = ownership || ownership_emails.is_some();
3836 let hotspots = hotspots || ownership;
3837 let args = HealthDispatchArgs {
3838 max_cyclomatic,
3839 max_cognitive,
3840 max_crap,
3841 top,
3842 sort,
3843 complexity,
3844 complexity_breakdown,
3845 file_scores,
3846 coverage_gaps,
3847 hotspots,
3848 ownership,
3849 ownership_emails: ownership_emails.map(EmailModeArg::to_config),
3850 targets,
3851 type_coupling,
3852 css,
3853 effort,
3854 score,
3855 min_score,
3856 min_severity: min_severity.map(HealthSeverityCli::to_health_severity),
3857 report_only,
3858 since: since.as_deref(),
3859 min_commits,
3860 save_snapshot: save_snapshot.as_ref(),
3861 trend,
3862 coverage: coverage.as_deref(),
3863 coverage_root: coverage_root.as_deref(),
3864 runtime_coverage: runtime_coverage.as_deref(),
3865 min_invocations_hot,
3866 min_observation_volume,
3867 low_traffic_threshold,
3868 };
3869 dispatch_health(dispatch, &args)
3870}
3871
3872fn dispatch_setup_hooks_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3873 let Command::SetupHooks {
3874 agent,
3875 dry_run,
3876 force,
3877 user,
3878 gitignore_claude,
3879 uninstall,
3880 } = command
3881 else {
3882 unreachable!("setup-hooks dispatcher only handles setup-hooks commands");
3883 };
3884
3885 setup_hooks::run_setup_hooks(&setup_hooks::SetupHooksOptions {
3886 root: dispatch.root,
3887 agent: *agent,
3888 dry_run: *dry_run,
3889 force: *force,
3890 user: *user,
3891 gitignore_claude: *gitignore_claude,
3892 uninstall: *uninstall,
3893 })
3894}
3895
3896fn dispatch_audit_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3897 let Command::Audit {
3898 production_dead_code,
3899 production_health,
3900 production_dupes,
3901 dead_code_baseline,
3902 health_baseline,
3903 dupes_baseline,
3904 max_crap,
3905 coverage,
3906 coverage_root,
3907 no_css,
3908 css_deep,
3909 no_css_deep,
3910 gate,
3911 runtime_coverage,
3912 min_invocations_hot,
3913 gate_marker,
3914 brief,
3915 max_decisions,
3916 walkthrough_guide,
3917 walkthrough_file,
3918 walkthrough,
3919 mark_viewed,
3920 show_cleared,
3921 show_deprioritized,
3922 } = command
3923 else {
3924 unreachable!("audit dispatcher only handles audit commands");
3925 };
3926
3927 let brief = brief || walkthrough_guide || walkthrough || walkthrough_file.is_some();
3930
3931 dispatch_audit(
3932 dispatch,
3933 &AuditDispatchArgs {
3934 production_dead_code,
3935 production_health,
3936 production_dupes,
3937 dead_code_baseline,
3938 health_baseline,
3939 dupes_baseline,
3940 max_crap,
3941 coverage,
3942 coverage_root,
3943 no_css,
3944 css_deep,
3945 no_css_deep,
3946 gate,
3947 runtime_coverage,
3948 min_invocations_hot,
3949 gate_marker,
3950 brief,
3951 max_decisions,
3952 walkthrough_guide,
3953 walkthrough_file,
3954 walkthrough,
3955 mark_viewed,
3956 show_cleared,
3957 show_deprioritized,
3958 },
3959 )
3960}
3961
3962fn dispatch_audit_cache_command(
3963 dispatch: &DispatchContext<'_>,
3964 subcommand: &AuditCacheCli,
3965) -> ExitCode {
3966 match subcommand {
3967 AuditCacheCli::Remove { dry_run, yes } => {
3968 if !*dry_run && !*yes && !std::io::stdin().is_terminal() {
3969 return emit_error(
3970 "audit-cache remove requires --yes (or --force) in non-interactive environments. Use --dry-run to preview removal first, then pass --yes to confirm.",
3971 2,
3972 dispatch.output,
3973 );
3974 }
3975 match base_worktree::remove_reusable_audit_caches(dispatch.root, *dry_run) {
3976 Ok(report) => {
3977 let action = if *dry_run { "would remove" } else { "removed" };
3978 if matches!(dispatch.output, fallow_config::OutputFormat::Json) {
3979 let value = serde_json::json!({
3980 "kind": "audit-cache-remove",
3981 "schema_version": 1,
3982 "command": "audit-cache remove",
3983 "root": dispatch.root,
3984 "dry_run": report.dry_run,
3985 "found": report.found,
3986 "would_remove": report.found.saturating_sub(report.skipped),
3987 "removed": report.removed,
3988 "skipped": report.skipped,
3989 "complete": report.skipped == 0,
3990 });
3991 let output_code = report::emit_report_json(
3992 &value,
3993 "audit cache removal",
3994 dispatch.json_style,
3995 );
3996 if output_code != ExitCode::SUCCESS {
3997 return output_code;
3998 }
3999 } else if !dispatch.quiet {
4000 println!(
4001 "audit cache: {action} {}, skipped {} for {}",
4002 if *dry_run {
4003 report.found.saturating_sub(report.skipped)
4004 } else {
4005 report.removed
4006 },
4007 report.skipped,
4008 dispatch.root.display(),
4009 );
4010 }
4011 if report.skipped == 0 {
4012 ExitCode::SUCCESS
4013 } else {
4014 ExitCode::from(2)
4015 }
4016 }
4017 Err(error) => emit_error(
4018 &format!(
4019 "failed to remove audit caches for {}: {error}",
4020 dispatch.root.display()
4021 ),
4022 2,
4023 dispatch.output,
4024 ),
4025 }
4026 }
4027 }
4028}
4029
4030fn dispatch_flags_command(dispatch: &DispatchContext<'_>, top: Option<usize>) -> ExitCode {
4031 let cli = dispatch.cli;
4032 let root = dispatch.root;
4033 let output = dispatch.output;
4034 let quiet = dispatch.quiet;
4035 let threads = dispatch.threads;
4036 let production = match resolve_production_modes(cli, root, output, false, false, false) {
4037 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::DeadCode),
4038 Err(code) => return code,
4039 };
4040 flags::run_flags(&flags::FlagsOptions {
4041 root,
4042 config_path: &cli.config,
4043 output,
4044 json_style: dispatch.json_style,
4045 no_cache: cli.no_cache,
4046 threads,
4047 quiet,
4048 allow_remote_extends: cli.allow_remote_extends,
4049 production,
4050 workspace: cli.workspace.as_deref(),
4051 changed_workspaces: cli.changed_workspaces.as_deref(),
4052 changed_since: cli.changed_since.as_deref(),
4053 explain: cli.explain,
4054 top,
4055 })
4056}
4057
4058fn dispatch_suppressions_command(
4059 dispatch: &DispatchContext<'_>,
4060 file: &[std::path::PathBuf],
4061) -> ExitCode {
4062 let cli = dispatch.cli;
4063 let root = dispatch.root;
4064 let output = dispatch.output;
4065 let production = match resolve_production_modes(cli, root, output, false, false, false) {
4066 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::DeadCode),
4067 Err(code) => return code,
4068 };
4069 suppressions::run_suppressions(&suppressions::SuppressionsOptions {
4070 root,
4071 config_path: &cli.config,
4072 output,
4073 json_style: dispatch.json_style,
4074 no_cache: cli.no_cache,
4075 threads: dispatch.threads,
4076 quiet: dispatch.quiet,
4077 allow_remote_extends: cli.allow_remote_extends,
4078 production,
4079 workspace: cli.workspace.as_deref(),
4080 changed_workspaces: cli.changed_workspaces.as_deref(),
4081 changed_since: cli.changed_since.as_deref(),
4082 file,
4083 })
4084}
4085
4086fn dispatch_guard_command(dispatch: &DispatchContext<'_>, files: &[String]) -> ExitCode {
4087 guard::run_guard(&guard::GuardOptions {
4088 root: dispatch.root,
4089 config_path: &dispatch.cli.config,
4090 output: dispatch.output,
4091 json_style: dispatch.json_style,
4092 quiet: dispatch.quiet,
4093 allow_remote_extends: dispatch.cli.allow_remote_extends,
4094 files,
4095 })
4096}
4097
4098fn dispatch_rule_pack_command(dispatch: &DispatchContext<'_>, subcommand: RulePackCli) -> ExitCode {
4099 let ctx = rule_pack::RulePackContext {
4100 root: dispatch.root,
4101 config_path: &dispatch.cli.config,
4102 output: dispatch.output,
4103 json_style: dispatch.json_style,
4104 quiet: dispatch.quiet,
4105 no_cache: dispatch.cli.no_cache,
4106 threads: Some(dispatch.threads),
4107 allow_remote_extends: dispatch.cli.allow_remote_extends,
4108 };
4109 rule_pack::run(&map_rule_pack_subcommand(subcommand), &ctx)
4110}
4111
4112fn map_rule_pack_subcommand(subcommand: RulePackCli) -> rule_pack::RulePackSubcommand {
4113 match subcommand {
4114 RulePackCli::Init {
4115 name,
4116 template,
4117 dir,
4118 no_config,
4119 } => rule_pack::RulePackSubcommand::Init(rule_pack::InitArgs {
4120 name,
4121 template,
4122 dir,
4123 no_config,
4124 }),
4125 RulePackCli::List => rule_pack::RulePackSubcommand::List,
4126 RulePackCli::Test { pack } => {
4127 rule_pack::RulePackSubcommand::Test(rule_pack::TestArgs { pack })
4128 }
4129 RulePackCli::Schema => rule_pack::RulePackSubcommand::Schema,
4130 }
4131}
4132
4133fn map_license_subcommand(sub: LicenseCli) -> license::LicenseSubcommand {
4134 match sub {
4135 LicenseCli::Activate {
4136 jwt,
4137 from_file,
4138 stdin,
4139 trial,
4140 email,
4141 } => license::LicenseSubcommand::Activate(license::ActivateArgs {
4142 raw_jwt: jwt,
4143 from_file,
4144 from_stdin: stdin,
4145 trial,
4146 email,
4147 }),
4148 LicenseCli::Status => license::LicenseSubcommand::Status,
4149 LicenseCli::Refresh => license::LicenseSubcommand::Refresh,
4150 LicenseCli::Deactivate => license::LicenseSubcommand::Deactivate,
4151 }
4152}
4153
4154fn map_telemetry_subcommand(sub: TelemetryCli) -> telemetry::TelemetryCommand {
4155 match sub {
4156 TelemetryCli::Status => telemetry::TelemetryCommand::Status,
4157 TelemetryCli::Enable => telemetry::TelemetryCommand::Enable,
4158 TelemetryCli::Disable => telemetry::TelemetryCommand::Disable,
4159 TelemetryCli::Inspect { example } => telemetry::TelemetryCommand::Inspect { example },
4160 }
4161}
4162
4163fn map_ci_subcommand(sub: CiCli) -> ci::CiCommand {
4164 match sub {
4165 command @ CiCli::PlanPrComment { .. } => map_ci_plan_pr_comment(command),
4166 command @ CiCli::PostPrComment { .. } => map_ci_post_pr_comment(command),
4167 command @ CiCli::PostReview { .. } => map_ci_post_review(command),
4168 command @ CiCli::PostCheckRun { .. } => map_ci_post_check_run(command),
4169 command @ CiCli::ReconcileReview { .. } => map_ci_reconcile_review(command),
4170 }
4171}
4172
4173fn map_ci_plan_pr_comment(command: CiCli) -> ci::CiCommand {
4174 let CiCli::PlanPrComment {
4175 body,
4176 marker_id,
4177 clean,
4178 existing_comment_id,
4179 existing_body,
4180 } = command
4181 else {
4182 unreachable!("ci plan-pr-comment mapper called with different variant");
4183 };
4184
4185 ci::CiCommand::PlanPrComment {
4186 body,
4187 marker_id,
4188 clean,
4189 existing_comment_id,
4190 existing_body,
4191 }
4192}
4193
4194fn map_ci_post_pr_comment(command: CiCli) -> ci::CiCommand {
4195 let CiCli::PostPrComment {
4196 provider,
4197 pr,
4198 mr,
4199 body,
4200 envelope,
4201 marker_id,
4202 clean,
4203 repo,
4204 project_id,
4205 api_url,
4206 dry_run,
4207 } = command
4208 else {
4209 unreachable!("ci post-pr-comment mapper called with different variant");
4210 };
4211
4212 ci::CiCommand::PostPrComment {
4213 provider: map_ci_provider(provider),
4214 target: pr.or(mr),
4215 body,
4216 envelope,
4217 marker_id,
4218 clean,
4219 repo,
4220 project_id,
4221 api_url,
4222 dry_run,
4223 }
4224}
4225
4226fn map_ci_post_review(command: CiCli) -> ci::CiCommand {
4227 let CiCli::PostReview {
4228 provider,
4229 pr,
4230 mr,
4231 envelope,
4232 repo,
4233 project_id,
4234 api_url,
4235 dry_run,
4236 } = command
4237 else {
4238 unreachable!("ci post-review mapper called with different variant");
4239 };
4240
4241 ci::CiCommand::PostReview {
4242 provider: map_ci_provider(provider),
4243 target: pr.or(mr),
4244 envelope,
4245 repo,
4246 project_id,
4247 api_url,
4248 dry_run,
4249 }
4250}
4251
4252fn map_ci_post_check_run(command: CiCli) -> ci::CiCommand {
4253 let CiCli::PostCheckRun {
4254 provider,
4255 decision,
4256 repo,
4257 head_sha,
4258 api_url,
4259 split_gates,
4260 dry_run,
4261 } = command
4262 else {
4263 unreachable!("ci post-check-run mapper called with different variant");
4264 };
4265
4266 ci::CiCommand::PostCheckRun {
4267 provider: map_ci_provider(provider),
4268 decision,
4269 repo,
4270 head_sha,
4271 api_url,
4272 split_gates,
4273 dry_run,
4274 }
4275}
4276
4277fn map_ci_reconcile_review(command: CiCli) -> ci::CiCommand {
4278 let CiCli::ReconcileReview {
4279 provider,
4280 pr,
4281 mr,
4282 envelope,
4283 repo,
4284 project_id,
4285 api_url,
4286 dry_run,
4287 } = command
4288 else {
4289 unreachable!("ci reconcile-review mapper called with different variant");
4290 };
4291
4292 ci::CiCommand::ReconcileReview {
4293 provider: map_ci_provider(provider),
4294 target: pr.or(mr),
4295 envelope,
4296 repo,
4297 project_id,
4298 api_url,
4299 dry_run,
4300 }
4301}
4302
4303fn map_ci_provider(provider: CiProviderArg) -> ci::CiProvider {
4304 match provider {
4305 CiProviderArg::Github => ci::CiProvider::Github,
4306 CiProviderArg::Gitlab => ci::CiProvider::Gitlab,
4307 }
4308}
4309
4310fn map_coverage_subcommand(sub: &CoverageCli, explain: bool) -> coverage::CoverageSubcommand {
4311 match sub {
4312 CoverageCli::Setup {
4313 yes,
4314 non_interactive,
4315 json,
4316 } => map_coverage_setup(*yes, *non_interactive, *json, explain),
4317 CoverageCli::Analyze { .. } => map_coverage_analyze(sub),
4318 CoverageCli::UploadInventory { .. } => map_coverage_upload_inventory(sub),
4319 CoverageCli::UploadSourceMaps { .. } => map_coverage_upload_source_maps(sub),
4320 CoverageCli::UploadStaticFindings { .. } => map_coverage_upload_static_findings(sub),
4321 }
4322}
4323
4324fn map_coverage_setup(
4325 yes: bool,
4326 non_interactive: bool,
4327 json: bool,
4328 explain: bool,
4329) -> coverage::CoverageSubcommand {
4330 coverage::CoverageSubcommand::Setup(coverage::SetupArgs {
4331 yes,
4332 non_interactive: non_interactive || json,
4333 json,
4334 explain,
4335 })
4336}
4337
4338fn map_coverage_analyze(sub: &CoverageCli) -> coverage::CoverageSubcommand {
4339 let CoverageCli::Analyze {
4340 runtime_coverage,
4341 cloud,
4342 api_key,
4343 api_endpoint,
4344 repo,
4345 project_id,
4346 coverage_period,
4347 environment,
4348 commit_sha,
4349 production,
4350 min_invocations_hot,
4351 min_observation_volume,
4352 low_traffic_threshold,
4353 top,
4354 blast_radius,
4355 importance,
4356 } = sub
4357 else {
4358 unreachable!("coverage analyze mapper called with non-analyze variant");
4359 };
4360 coverage::CoverageSubcommand::Analyze(coverage::AnalyzeArgs {
4361 runtime_coverage: runtime_coverage.clone(),
4362 cloud: *cloud,
4363 api_key: api_key.clone(),
4364 api_endpoint: api_endpoint.clone(),
4365 repo: repo.clone(),
4366 project_id: project_id.clone(),
4367 coverage_period: *coverage_period,
4368 environment: environment.clone(),
4369 commit_sha: commit_sha.clone(),
4370 production: *production,
4371 min_invocations_hot: *min_invocations_hot,
4372 min_observation_volume: *min_observation_volume,
4373 low_traffic_threshold: *low_traffic_threshold,
4374 top: *top,
4375 blast_radius: *blast_radius,
4376 importance: *importance,
4377 })
4378}
4379
4380fn map_coverage_upload_inventory(sub: &CoverageCli) -> coverage::CoverageSubcommand {
4381 let CoverageCli::UploadInventory {
4382 api_key,
4383 api_endpoint,
4384 project_id,
4385 git_sha,
4386 allow_dirty,
4387 exclude_paths,
4388 path_prefix,
4389 dry_run,
4390 with_callers,
4391 ignore_upload_errors,
4392 } = sub
4393 else {
4394 unreachable!("coverage inventory mapper called with non-inventory variant");
4395 };
4396 coverage::CoverageSubcommand::UploadInventory(coverage::UploadInventoryArgs {
4397 api_key: api_key.clone(),
4398 api_endpoint: api_endpoint.clone(),
4399 project_id: project_id.clone(),
4400 git_sha: git_sha.clone(),
4401 allow_dirty: *allow_dirty,
4402 exclude_paths: exclude_paths.clone(),
4403 path_prefix: path_prefix.clone(),
4404 dry_run: *dry_run,
4405 with_callers: *with_callers,
4406 ignore_upload_errors: *ignore_upload_errors,
4407 })
4408}
4409
4410fn map_coverage_upload_source_maps(sub: &CoverageCli) -> coverage::CoverageSubcommand {
4411 let CoverageCli::UploadSourceMaps {
4412 dir,
4413 include,
4414 exclude,
4415 repo,
4416 git_sha,
4417 endpoint,
4418 strip_path,
4419 dry_run,
4420 concurrency,
4421 fail_fast,
4422 } = sub
4423 else {
4424 unreachable!("coverage source-map mapper called with non-source-map variant");
4425 };
4426 coverage::CoverageSubcommand::UploadSourceMaps(coverage::UploadSourceMapsArgs {
4427 dir: dir.clone(),
4428 include: include.clone(),
4429 exclude: exclude.clone(),
4430 repo: repo.clone(),
4431 git_sha: git_sha.clone(),
4432 endpoint: endpoint.clone(),
4433 strip_path: *strip_path,
4434 dry_run: *dry_run,
4435 concurrency: *concurrency,
4436 fail_fast: *fail_fast,
4437 })
4438}
4439
4440fn map_coverage_upload_static_findings(sub: &CoverageCli) -> coverage::CoverageSubcommand {
4441 let CoverageCli::UploadStaticFindings {
4442 api_key,
4443 api_endpoint,
4444 project_id,
4445 git_sha,
4446 allow_dirty,
4447 dry_run,
4448 ignore_upload_errors,
4449 } = sub
4450 else {
4451 unreachable!("coverage static-findings mapper called with non-static variant");
4452 };
4453 coverage::CoverageSubcommand::UploadStaticFindings(coverage::UploadStaticFindingsArgs {
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 dry_run: *dry_run,
4460 ignore_upload_errors: *ignore_upload_errors,
4461 })
4462}
4463
4464struct CheckDispatchArgs {
4465 filters: IssueFilters,
4466 trace_opts: TraceOptions,
4467 include_dupes: bool,
4468 type_aware: bool,
4469 type_aware_project: Vec<std::path::PathBuf>,
4470 type_aware_require: Option<TypeAwareRequireArg>,
4471 top: Option<usize>,
4472 file: Vec<std::path::PathBuf>,
4473}
4474
4475#[derive(Clone, Copy)]
4476struct ListDispatchArgs {
4477 entry_points: bool,
4478 files: bool,
4479 plugins: bool,
4480 boundaries: bool,
4481 workspaces: bool,
4482}
4483
4484impl ListDispatchArgs {
4485 fn workspaces() -> Self {
4486 Self {
4487 entry_points: false,
4488 files: false,
4489 plugins: false,
4490 boundaries: false,
4491 workspaces: true,
4492 }
4493 }
4494}
4495
4496fn dispatch_viz(
4497 dispatch: &DispatchContext<'_>,
4498 output_path: Option<&std::path::Path>,
4499 no_open: bool,
4500 format: viz::VizFormat,
4501) -> ExitCode {
4502 let cli = dispatch.cli;
4503 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
4504 Ok(production) => production,
4505 Err(code) => return code,
4506 };
4507 viz::run_viz(&viz::VizOptions {
4508 root: dispatch.root,
4509 config_path: &cli.config,
4510 no_cache: cli.no_cache,
4511 threads: dispatch.threads,
4512 quiet: dispatch.quiet,
4513 production,
4514 allow_remote_extends: cli.allow_remote_extends,
4515 output_path,
4516 no_open,
4517 format,
4518 })
4519}
4520
4521fn dispatch_watch(dispatch: &DispatchContext<'_>, no_clear: bool) -> ExitCode {
4522 let cli = dispatch.cli;
4523 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
4524 Ok(production) => production,
4525 Err(code) => return code,
4526 };
4527 watch::run_watch(&watch::WatchOptions {
4528 root: dispatch.root,
4529 config_path: &cli.config,
4530 output: dispatch.output,
4531 json_style: dispatch.json_style,
4532 no_cache: cli.no_cache,
4533 threads: dispatch.threads,
4534 quiet: dispatch.quiet,
4535 allow_remote_extends: cli.allow_remote_extends,
4536 production,
4537 clear_screen: !no_clear,
4538 explain: cli.explain,
4539 include_entry_exports: cli.include_entry_exports,
4540 type_aware: cli.type_aware,
4541 type_aware_projects: &cli.type_aware_project,
4542 type_aware_require: cli.type_aware_require.map(Into::into),
4543 })
4544}
4545
4546#[derive(Clone, Copy)]
4547struct FixDispatchArgs {
4548 dry_run: bool,
4549 yes: bool,
4550 no_create_config: bool,
4551}
4552
4553fn dispatch_fix(dispatch: &DispatchContext<'_>, args: FixDispatchArgs) -> ExitCode {
4554 let cli = dispatch.cli;
4555 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
4556 Ok(production) => production,
4557 Err(code) => return code,
4558 };
4559 fix::run_fix(&fix::FixOptions {
4560 root: dispatch.root,
4561 config_path: &cli.config,
4562 output: dispatch.output,
4563 json_style: dispatch.json_style,
4564 no_cache: cli.no_cache,
4565 threads: dispatch.threads,
4566 quiet: dispatch.quiet,
4567 allow_remote_extends: cli.allow_remote_extends,
4568 dry_run: args.dry_run,
4569 yes: args.yes,
4570 production,
4571 no_create_config: args.no_create_config,
4572 type_aware: cli.type_aware,
4573 type_aware_projects: &cli.type_aware_project,
4574 type_aware_require: cli.type_aware_require.map(Into::into),
4575 })
4576}
4577
4578fn dispatch_list(dispatch: &DispatchContext<'_>, args: ListDispatchArgs) -> 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 list::run_list(&ListOptions {
4585 root: dispatch.root,
4586 config_path: &cli.config,
4587 output: dispatch.output,
4588 json_style: dispatch.json_style,
4589 threads: dispatch.threads,
4590 no_cache: cli.no_cache,
4591 entry_points: args.entry_points,
4592 files: args.files,
4593 plugins: args.plugins,
4594 boundaries: args.boundaries,
4595 workspaces: args.workspaces,
4596 production,
4597 allow_remote_extends: cli.allow_remote_extends,
4598 })
4599}
4600
4601fn dispatch_check(dispatch: &DispatchContext<'_>, args: &CheckDispatchArgs) -> ExitCode {
4602 let cli = dispatch.cli;
4603 let (output, quiet, fail_on_issues) =
4604 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
4605 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
4606 Ok(production) => production,
4607 Err(code) => return code,
4608 };
4609 if let Some(code) = validate_type_aware_check_options(dispatch, args) {
4610 return code;
4611 }
4612 check::run_check(&CheckOptions {
4613 root: dispatch.root,
4614 config_path: &cli.config,
4615 output,
4616 json_style: dispatch.json_style,
4617 no_cache: cli.no_cache,
4618 threads: dispatch.threads,
4619 quiet,
4620 allow_remote_extends: cli.allow_remote_extends,
4621 fail_on_issues,
4622 filters: &args.filters,
4623 changed_since: cli.changed_since.as_deref(),
4624 diff_index: None,
4625 use_shared_diff_index: true,
4626 baseline: cli.baseline.as_deref(),
4627 save_baseline: cli.save_baseline.as_deref(),
4628 sarif_file: cli.sarif_file.as_deref(),
4629 production,
4630 production_override: Some(production),
4631 workspace: cli.workspace.as_deref(),
4632 changed_workspaces: cli.changed_workspaces.as_deref(),
4633 group_by: cli.group_by,
4634 include_dupes: args.include_dupes,
4635 type_aware: args.type_aware,
4636 type_aware_projects: &args.type_aware_project,
4637 type_aware_require: args.type_aware_require.map(Into::into),
4638 trace_opts: &args.trace_opts,
4639 explain: cli.explain,
4640 top: args.top,
4641 file: &args.file,
4642 include_entry_exports: cli.include_entry_exports,
4643 summary: cli.summary,
4644 regression_opts: dispatch.regression_opts(
4645 cli.changed_since.is_some()
4646 || cli.workspace.is_some()
4647 || cli.changed_workspaces.is_some()
4648 || !args.file.is_empty(),
4649 ),
4650 retain_modules_for_health: false,
4651 defer_performance: false,
4652 })
4653}
4654
4655fn validate_type_aware_check_options(
4656 dispatch: &DispatchContext<'_>,
4657 args: &CheckDispatchArgs,
4658) -> Option<ExitCode> {
4659 let output = dispatch.output;
4660 if !args.type_aware_project.is_empty() && !args.type_aware {
4661 return Some(emit_error(
4662 "--type-aware-project requires --type-aware",
4663 2,
4664 output,
4665 ));
4666 }
4667 if args.type_aware_require.is_some() && !args.type_aware {
4668 return Some(emit_error(
4669 "--type-aware-require requires --type-aware",
4670 2,
4671 output,
4672 ));
4673 }
4674 if args.trace_opts.symbol_impact.is_some() && !args.type_aware {
4675 return Some(emit_error(
4676 "--symbol-impact requires --type-aware",
4677 2,
4678 output,
4679 ));
4680 }
4681 let focused_output = args.trace_opts.trace_export.is_some()
4682 || args.trace_opts.trace_file.is_some()
4683 || args.trace_opts.trace_dependency.is_some()
4684 || args.trace_opts.impact_closure.is_some()
4685 || args.trace_opts.symbol_impact.is_some();
4686 if focused_output
4687 && !matches!(
4688 output,
4689 fallow_config::OutputFormat::Human | fallow_config::OutputFormat::Json
4690 )
4691 {
4692 return Some(emit_error(
4693 "focused trace and impact queries support human and JSON output",
4694 2,
4695 output,
4696 ));
4697 }
4698 if args.type_aware
4699 && !matches!(
4700 output,
4701 fallow_config::OutputFormat::Human
4702 | fallow_config::OutputFormat::Json
4703 | fallow_config::OutputFormat::Sarif
4704 | fallow_config::OutputFormat::Compact
4705 | fallow_config::OutputFormat::Markdown
4706 | fallow_config::OutputFormat::CodeClimate
4707 )
4708 {
4709 return Some(emit_error(
4710 "--type-aware supports human, JSON, SARIF, compact, markdown, and CodeClimate output; pair CodeClimate with the JSON artifact to preserve semantic provenance",
4711 2,
4712 output,
4713 ));
4714 }
4715 None
4716}
4717
4718fn resolve_ignore_imports(ignore_imports: bool, no_ignore_imports: bool) -> Option<bool> {
4724 if no_ignore_imports {
4725 Some(false)
4726 } else if ignore_imports {
4727 Some(true)
4728 } else {
4729 None
4730 }
4731}
4732
4733struct DupesDispatchArgs {
4734 mode: Option<DupesMode>,
4735 min_tokens: Option<usize>,
4736 min_lines: Option<usize>,
4737 min_occurrences: Option<usize>,
4738 threshold: Option<f64>,
4739 skip_local: bool,
4740 cross_language: bool,
4741 ignore_imports: bool,
4742 no_ignore_imports: bool,
4743 top: Option<usize>,
4744 trace: Option<String>,
4745}
4746
4747fn dispatch_dupes(dispatch: &DispatchContext<'_>, args: &DupesDispatchArgs) -> ExitCode {
4748 let cli = dispatch.cli;
4749 let (output, quiet, _fail_on_issues) =
4750 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
4751 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::Dupes) {
4752 Ok(production) => production,
4753 Err(code) => return code,
4754 };
4755 dupes::run_dupes(&DupesOptions {
4756 root: dispatch.root,
4757 config_path: &cli.config,
4758 output,
4759 json_style: dispatch.json_style,
4760 no_cache: cli.no_cache,
4761 threads: dispatch.threads,
4762 quiet,
4763 allow_remote_extends: cli.allow_remote_extends,
4764 mode: args.mode,
4765 min_tokens: args.min_tokens,
4766 min_lines: args.min_lines,
4767 min_occurrences: args.min_occurrences,
4768 threshold: args.threshold,
4769 skip_local: args.skip_local,
4770 cross_language: args.cross_language,
4771 ignore_imports: resolve_ignore_imports(args.ignore_imports, args.no_ignore_imports),
4772 top: args.top,
4773 baseline_path: cli.baseline.as_deref(),
4774 save_baseline_path: cli.save_baseline.as_deref(),
4775 production,
4776 production_override: Some(production),
4777 trace: args.trace.as_deref(),
4778 changed_since: cli.changed_since.as_deref(),
4779 diff_index: None,
4780 use_shared_diff_index: true,
4781 changed_files: None,
4782 workspace: cli.workspace.as_deref(),
4783 changed_workspaces: cli.changed_workspaces.as_deref(),
4784 explain: cli.explain,
4785 explain_skipped: cli.explain_skipped,
4786 summary: cli.summary,
4787 group_by: cli.group_by,
4788 performance: cli.performance,
4789 })
4790}
4791
4792struct AuditDispatchArgs {
4793 production_dead_code: bool,
4794 production_health: bool,
4795 production_dupes: bool,
4796 dead_code_baseline: Option<PathBuf>,
4797 health_baseline: Option<PathBuf>,
4798 dupes_baseline: Option<PathBuf>,
4799 max_crap: Option<f64>,
4800 coverage: Option<PathBuf>,
4801 coverage_root: Option<PathBuf>,
4802 no_css: bool,
4803 css_deep: bool,
4804 no_css_deep: bool,
4805 gate: Option<AuditGateArg>,
4806 runtime_coverage: Option<PathBuf>,
4807 min_invocations_hot: u64,
4808 gate_marker: Option<String>,
4809 brief: bool,
4810 max_decisions: usize,
4811 walkthrough_guide: bool,
4813 walkthrough_file: Option<PathBuf>,
4816 walkthrough: bool,
4818 mark_viewed: Vec<PathBuf>,
4820 show_cleared: bool,
4822 show_deprioritized: bool,
4824}
4825
4826struct ResolvedAuditInputs {
4827 audit_cfg: fallow_config::AuditConfig,
4828 cache_dir: PathBuf,
4829 production: ProductionModes,
4830 dead_code_baseline: Option<PathBuf>,
4831 health_baseline: Option<PathBuf>,
4832 dupes_baseline: Option<PathBuf>,
4833 coverage: Option<PathBuf>,
4834}
4835
4836fn dispatch_audit(dispatch: &DispatchContext<'_>, args: &AuditDispatchArgs) -> ExitCode {
4837 let cli = dispatch.cli;
4838 let output = dispatch.output;
4839
4840 if cli.baseline.is_some() || cli.save_baseline.is_some() {
4841 return emit_error(
4842 "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>`)",
4843 2,
4844 output,
4845 );
4846 }
4847
4848 let inputs = match resolve_audit_inputs(dispatch, args) {
4849 Ok(inputs) => inputs,
4850 Err(code) => return code,
4851 };
4852
4853 run_resolved_audit(dispatch, args, &inputs)
4854}
4855
4856fn resolve_audit_inputs(
4857 dispatch: &DispatchContext<'_>,
4858 args: &AuditDispatchArgs,
4859) -> Result<ResolvedAuditInputs, ExitCode> {
4860 let cli = dispatch.cli;
4861 let root = dispatch.root;
4862 let output = dispatch.output;
4863 let config = load_config(
4864 root,
4865 &cli.config,
4866 LoadConfigArgs {
4867 output,
4868 no_cache: cli.no_cache,
4869 threads: dispatch.threads,
4870 production: cli.production,
4871 quiet: dispatch.quiet,
4872 allow_remote_extends: cli.allow_remote_extends,
4873 },
4874 )?;
4875 let cache_dir = config.cache_dir.clone();
4876 let audit_cfg = config.audit;
4877 let production = resolve_production_modes(
4878 cli,
4879 root,
4880 output,
4881 args.production_dead_code,
4882 args.production_health,
4883 args.production_dupes,
4884 )?;
4885 let resolved_dead_code_baseline = resolve_audit_baseline_path(
4886 root,
4887 args.dead_code_baseline.as_deref(),
4888 audit_cfg.dead_code_baseline.as_deref(),
4889 );
4890 let resolved_health_baseline = resolve_audit_baseline_path(
4891 root,
4892 args.health_baseline.as_deref(),
4893 audit_cfg.health_baseline.as_deref(),
4894 );
4895 let resolved_dupes_baseline = resolve_audit_baseline_path(
4896 root,
4897 args.dupes_baseline.as_deref(),
4898 audit_cfg.dupes_baseline.as_deref(),
4899 );
4900 let coverage = args
4901 .coverage
4902 .clone()
4903 .or_else(|| std::env::var("FALLOW_COVERAGE").ok().map(PathBuf::from));
4904
4905 Ok(ResolvedAuditInputs {
4906 audit_cfg,
4907 cache_dir,
4908 production,
4909 dead_code_baseline: resolved_dead_code_baseline,
4910 health_baseline: resolved_health_baseline,
4911 dupes_baseline: resolved_dupes_baseline,
4912 coverage,
4913 })
4914}
4915
4916fn audit_css_enabled(config: &fallow_config::AuditConfig, args: &AuditDispatchArgs) -> bool {
4917 !args.no_css && config.css.unwrap_or(true)
4918}
4919
4920fn audit_css_deep_enabled(config: &fallow_config::AuditConfig, args: &AuditDispatchArgs) -> bool {
4921 audit_css_enabled(config, args)
4922 && !args.no_css_deep
4923 && (args.css_deep || config.css_deep.unwrap_or(true))
4924}
4925
4926fn run_resolved_audit(
4927 dispatch: &DispatchContext<'_>,
4928 args: &AuditDispatchArgs,
4929 inputs: &ResolvedAuditInputs,
4930) -> ExitCode {
4931 let cli = dispatch.cli;
4932 audit::run_audit_with_type_aware(
4933 &audit::AuditOptions {
4934 root: dispatch.root,
4935 config_path: &cli.config,
4936 cache_dir: &inputs.cache_dir,
4937 output: dispatch.output,
4938 json_style: dispatch.json_style,
4939 no_cache: cli.no_cache,
4940 threads: dispatch.threads,
4941 quiet: dispatch.quiet,
4942 allow_remote_extends: cli.allow_remote_extends,
4943 changed_since: cli.changed_since.as_deref(),
4944 production: cli.production,
4945 production_dead_code: Some(inputs.production.dead_code),
4946 production_health: Some(inputs.production.health),
4947 production_dupes: Some(inputs.production.dupes),
4948 workspace: cli.workspace.as_deref(),
4949 changed_workspaces: cli.changed_workspaces.as_deref(),
4950 explain: cli.explain,
4951 explain_skipped: cli.explain_skipped,
4952 performance: cli.performance,
4953 group_by: cli.group_by,
4954 dead_code_baseline: inputs.dead_code_baseline.as_deref(),
4955 health_baseline: inputs.health_baseline.as_deref(),
4956 dupes_baseline: inputs.dupes_baseline.as_deref(),
4957 max_crap: args.max_crap,
4958 coverage: inputs.coverage.as_deref(),
4959 coverage_root: args.coverage_root.as_deref(),
4960 gate: args.gate.map_or(inputs.audit_cfg.gate, Into::into),
4961 include_entry_exports: cli.include_entry_exports,
4962 css: audit_css_enabled(&inputs.audit_cfg, args),
4966 css_deep: audit_css_deep_enabled(&inputs.audit_cfg, args),
4967 runtime_coverage: args.runtime_coverage.as_deref(),
4968 min_invocations_hot: args.min_invocations_hot,
4969 brief: args.brief,
4970 max_decisions: args.max_decisions,
4971 walkthrough_guide: args.walkthrough_guide,
4972 walkthrough: args.walkthrough,
4973 mark_viewed: &args.mark_viewed,
4974 show_cleared: args.show_cleared,
4975 walkthrough_file: args.walkthrough_file.as_deref(),
4976 show_deprioritized: args.show_deprioritized,
4977 },
4978 args.gate_marker.as_deref(),
4979 audit::AuditTypeAwareOptions {
4980 enabled: cli.type_aware,
4981 projects: &cli.type_aware_project,
4982 require: cli.type_aware_require.map(Into::into),
4983 },
4984 )
4985}
4986
4987fn dispatch_decision_surface(dispatch: &DispatchContext<'_>, max_decisions: usize) -> ExitCode {
4991 let args = decision_surface_audit_args(max_decisions);
4992 let inputs = match resolve_audit_inputs(dispatch, &args) {
4993 Ok(inputs) => inputs,
4994 Err(code) => return code,
4995 };
4996 audit::run_decision_surface(&decision_surface_audit_options(
4997 dispatch,
4998 &inputs,
4999 max_decisions,
5000 ))
5001}
5002
5003fn decision_surface_audit_args(max_decisions: usize) -> AuditDispatchArgs {
5004 AuditDispatchArgs {
5005 production_dead_code: false,
5006 production_health: false,
5007 production_dupes: false,
5008 dead_code_baseline: None,
5009 health_baseline: None,
5010 dupes_baseline: None,
5011 max_crap: None,
5012 coverage: None,
5013 coverage_root: None,
5014 no_css: true,
5015 css_deep: false,
5016 no_css_deep: false,
5017 gate: None,
5018 runtime_coverage: None,
5019 min_invocations_hot: 0,
5020 gate_marker: None,
5021 brief: true,
5022 max_decisions,
5023 walkthrough_guide: false,
5024 walkthrough_file: None,
5025 walkthrough: false,
5026 mark_viewed: Vec::new(),
5027 show_cleared: false,
5028 show_deprioritized: false,
5029 }
5030}
5031
5032fn decision_surface_audit_options<'a>(
5033 dispatch: &'a DispatchContext<'a>,
5034 inputs: &'a ResolvedAuditInputs,
5035 max_decisions: usize,
5036) -> audit::AuditOptions<'a> {
5037 let cli = dispatch.cli;
5038 audit::AuditOptions {
5039 root: dispatch.root,
5040 config_path: &cli.config,
5041 cache_dir: &inputs.cache_dir,
5042 output: dispatch.output,
5043 json_style: dispatch.json_style,
5044 no_cache: cli.no_cache,
5045 threads: dispatch.threads,
5046 quiet: dispatch.quiet,
5047 allow_remote_extends: cli.allow_remote_extends,
5048 changed_since: cli.changed_since.as_deref(),
5049 production: cli.production,
5050 production_dead_code: Some(inputs.production.dead_code),
5051 production_health: Some(inputs.production.health),
5052 production_dupes: Some(inputs.production.dupes),
5053 workspace: cli.workspace.as_deref(),
5054 changed_workspaces: cli.changed_workspaces.as_deref(),
5055 explain: cli.explain,
5056 explain_skipped: cli.explain_skipped,
5057 performance: cli.performance,
5058 group_by: cli.group_by,
5059 dead_code_baseline: inputs.dead_code_baseline.as_deref(),
5060 health_baseline: inputs.health_baseline.as_deref(),
5061 dupes_baseline: inputs.dupes_baseline.as_deref(),
5062 max_crap: None,
5063 coverage: None,
5064 coverage_root: None,
5065 gate: inputs.audit_cfg.gate,
5066 include_entry_exports: cli.include_entry_exports,
5067 css: false,
5069 css_deep: false,
5070 runtime_coverage: None,
5071 min_invocations_hot: 0,
5072 brief: true,
5073 max_decisions,
5074 walkthrough_guide: false,
5075 walkthrough: false,
5076 mark_viewed: &[],
5077 show_cleared: false,
5078 walkthrough_file: None,
5079 show_deprioritized: false,
5080 }
5081}
5082
5083struct HealthDispatchArgs<'a> {
5084 max_cyclomatic: Option<u16>,
5085 max_cognitive: Option<u16>,
5086 max_crap: Option<f64>,
5087 top: Option<usize>,
5088 sort: health::SortBy,
5089 complexity: bool,
5090 complexity_breakdown: bool,
5091 file_scores: bool,
5092 coverage_gaps: bool,
5093 hotspots: bool,
5094 ownership: bool,
5095 ownership_emails: Option<fallow_config::EmailMode>,
5096 targets: bool,
5097 type_coupling: bool,
5098 css: bool,
5099 effort: Option<EffortFilter>,
5100 score: bool,
5101 min_score: Option<f64>,
5102 min_severity: Option<fallow_output::FindingSeverity>,
5103 report_only: bool,
5104 since: Option<&'a str>,
5105 min_commits: Option<u32>,
5106 save_snapshot: Option<&'a Option<String>>,
5107 trend: bool,
5108 coverage: Option<&'a std::path::Path>,
5109 coverage_root: Option<&'a std::path::Path>,
5110 runtime_coverage: Option<&'a std::path::Path>,
5111 min_invocations_hot: u64,
5112 min_observation_volume: Option<u32>,
5113 low_traffic_threshold: Option<f64>,
5114}
5115
5116struct ResolvedHealthCoverageInputs {
5117 coverage: Option<PathBuf>,
5118 coverage_root: Option<PathBuf>,
5119}
5120
5121fn resolve_health_coverage_inputs(
5122 dispatch: &DispatchContext<'_>,
5123 cli_coverage: Option<&std::path::Path>,
5124 cli_coverage_root: Option<&std::path::Path>,
5125) -> Result<ResolvedHealthCoverageInputs, ExitCode> {
5126 let env_coverage = path_from_env("FALLOW_COVERAGE");
5127 let env_coverage_root = path_from_env("FALLOW_COVERAGE_ROOT");
5128 let needs_config_coverage = cli_coverage.is_none() && env_coverage.is_none();
5129 let needs_config_coverage_root = cli_coverage_root.is_none() && env_coverage_root.is_none();
5130 let config_health = if needs_config_coverage || needs_config_coverage_root {
5131 Some(
5132 load_config(
5133 dispatch.root,
5134 &dispatch.cli.config,
5135 LoadConfigArgs {
5136 output: dispatch.output,
5137 no_cache: dispatch.cli.no_cache,
5138 threads: dispatch.threads,
5139 production: dispatch.cli.production,
5140 quiet: dispatch.quiet,
5141 allow_remote_extends: dispatch.cli.allow_remote_extends,
5142 },
5143 )?
5144 .health,
5145 )
5146 } else {
5147 None
5148 };
5149
5150 Ok(ResolvedHealthCoverageInputs {
5151 coverage: cli_coverage
5152 .map(std::path::Path::to_path_buf)
5153 .or(env_coverage)
5154 .or_else(|| {
5155 config_health
5156 .as_ref()
5157 .and_then(|health| health.coverage.clone())
5158 }),
5159 coverage_root: cli_coverage_root
5160 .map(std::path::Path::to_path_buf)
5161 .or(env_coverage_root)
5162 .or_else(|| {
5163 config_health
5164 .as_ref()
5165 .and_then(|health| health.coverage_root.clone())
5166 }),
5167 })
5168}
5169
5170fn path_from_env(name: &str) -> Option<PathBuf> {
5171 std::env::var_os(name)
5172 .filter(|value| !value.is_empty())
5173 .map(PathBuf::from)
5174}
5175
5176fn validate_health_report_only_gate(
5177 report_only: bool,
5178 min_score: Option<f64>,
5179 min_severity: Option<fallow_output::FindingSeverity>,
5180 output: fallow_config::OutputFormat,
5181) -> Result<(), ExitCode> {
5182 if report_only && (min_score.is_some() || min_severity.is_some()) {
5183 return Err(emit_error(
5184 "--report-only cannot be combined with --min-score or --min-severity. \
5185 --report-only always exits 0; drop it to gate on score/severity, or \
5186 drop the gate flags to stay advisory.",
5187 2,
5188 output,
5189 ));
5190 }
5191
5192 Ok(())
5193}
5194
5195fn resolve_runtime_coverage_options(
5196 runtime_coverage: Option<&std::path::Path>,
5197 min_invocations_hot: u64,
5198 min_observation_volume: Option<u32>,
5199 low_traffic_threshold: Option<f64>,
5200 output: fallow_config::OutputFormat,
5201) -> Result<Option<fallow_engine::health::RuntimeCoverageOptions>, ExitCode> {
5202 let Some(path) = runtime_coverage else {
5203 return Ok(None);
5204 };
5205
5206 health::coverage::prepare_options(
5207 path,
5208 min_invocations_hot,
5209 min_observation_volume,
5210 low_traffic_threshold,
5211 output,
5212 )
5213 .map(Some)
5214}
5215
5216fn dispatch_health(dispatch: &DispatchContext<'_>, args: &HealthDispatchArgs<'_>) -> ExitCode {
5217 let cli = dispatch.cli;
5218 let root = dispatch.root;
5219 let (output, _quiet, _fail_on_issues) =
5220 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
5221 if let Err(code) = validate_health_report_only_gate(
5222 args.report_only,
5223 args.min_score,
5224 args.min_severity,
5225 output,
5226 ) {
5227 return code;
5228 }
5229 let runtime_coverage = match resolve_runtime_coverage_options(
5230 args.runtime_coverage,
5231 args.min_invocations_hot,
5232 args.min_observation_volume,
5233 args.low_traffic_threshold,
5234 output,
5235 ) {
5236 Ok(options) => options,
5237 Err(code) => return code,
5238 };
5239 let production = match resolve_production_modes(cli, root, output, false, false, false) {
5240 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::Health),
5241 Err(code) => return code,
5242 };
5243 let coverage_inputs =
5244 match resolve_health_coverage_inputs(dispatch, args.coverage, args.coverage_root) {
5245 Ok(inputs) => inputs,
5246 Err(code) => return code,
5247 };
5248 let run = derive_health_dispatch_run(args, output, &coverage_inputs, runtime_coverage);
5249 run_health_dispatch(dispatch, args, ResolvedHealthDispatch { run, production })
5250}
5251
5252fn derive_health_dispatch_run<'a>(
5253 args: &'a HealthDispatchArgs<'a>,
5254 output: fallow_config::OutputFormat,
5255 coverage_inputs: &'a ResolvedHealthCoverageInputs,
5256 runtime_coverage: Option<fallow_engine::health::RuntimeCoverageOptions>,
5257) -> fallow_engine::health::HealthRunOptions<'a> {
5258 let mut run = fallow_engine::health::derive_health_run_options(
5259 fallow_engine::health::HealthRunOptionsInput {
5260 output,
5261 thresholds: health_threshold_overrides(args),
5262 top: args.top,
5263 sort: args.sort.clone().into(),
5264 complexity: args.complexity,
5265 file_scores: args.file_scores,
5266 coverage_gaps: args.coverage_gaps,
5267 hotspots: args.hotspots,
5268 ownership: args.ownership,
5269 ownership_emails: args.ownership_emails,
5270 targets: args.targets,
5271 css: args.css,
5272 effort: args.effort.map(EffortFilter::to_estimate),
5273 score: args.score,
5274 gates: health_gate_options(args),
5275 snapshot_requested: args.save_snapshot.is_some(),
5276 trend: args.trend,
5277 since: args.since,
5278 min_commits: args.min_commits,
5279 coverage_inputs: health_coverage_inputs(coverage_inputs),
5280 runtime_coverage,
5281 },
5282 );
5283 if args.type_coupling && !run.sections.any_section {
5284 run.sections = fallow_engine::health::DerivedHealthSections {
5285 any_section: true,
5286 complexity: false,
5287 file_scores: false,
5288 coverage_gaps: false,
5289 hotspots: false,
5290 targets: false,
5291 css: false,
5292 score: false,
5293 force_full: false,
5294 score_only_output: false,
5295 };
5296 }
5297 run
5298}
5299
5300fn health_threshold_overrides(
5301 args: &HealthDispatchArgs<'_>,
5302) -> fallow_engine::health::HealthThresholdOverrides {
5303 fallow_engine::health::HealthThresholdOverrides {
5304 max_cyclomatic: args.max_cyclomatic,
5305 max_cognitive: args.max_cognitive,
5306 max_crap: args.max_crap,
5307 }
5308}
5309
5310fn health_gate_options(args: &HealthDispatchArgs<'_>) -> fallow_engine::health::HealthGateOptions {
5311 fallow_engine::health::HealthGateOptions {
5312 min_score: args.min_score,
5313 min_severity: args.min_severity,
5314 report_only: args.report_only,
5315 }
5316}
5317
5318fn health_coverage_inputs(
5319 coverage_inputs: &ResolvedHealthCoverageInputs,
5320) -> fallow_engine::health::HealthCoverageInputs<'_> {
5321 fallow_engine::health::HealthCoverageInputs {
5322 coverage: coverage_inputs.coverage.as_deref(),
5323 coverage_root: coverage_inputs.coverage_root.as_deref(),
5324 }
5325}
5326
5327struct ResolvedHealthDispatch<'a> {
5331 run: fallow_engine::health::HealthRunOptions<'a>,
5332 production: bool,
5333}
5334
5335fn run_health_dispatch(
5338 dispatch: &DispatchContext<'_>,
5339 args: &HealthDispatchArgs<'_>,
5340 resolved: ResolvedHealthDispatch<'_>,
5341) -> ExitCode {
5342 let cli = dispatch.cli;
5343 let (output, quiet, _fail_on_issues) =
5344 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
5345 let run = resolved.run;
5346 let sections = run.sections;
5347 let production = resolved.production;
5348 health::run_health(
5349 &HealthOptions {
5350 root: dispatch.root,
5351 config_path: &cli.config,
5352 output,
5353 no_cache: cli.no_cache,
5354 threads: dispatch.threads,
5355 quiet,
5356 thresholds: run.thresholds,
5357 top: run.top,
5358 sort: run.sort,
5359 production,
5360 production_override: Some(production),
5361 allow_remote_extends: cli.allow_remote_extends,
5362 changed_since: cli.changed_since.as_deref(),
5363 diff_index: None,
5364 use_shared_diff_index: true,
5365 workspace: cli.workspace.as_deref(),
5366 changed_workspaces: cli.changed_workspaces.as_deref(),
5367 baseline: cli.baseline.as_deref(),
5368 save_baseline: cli.save_baseline.as_deref(),
5369 complexity: sections.complexity,
5370 file_scores: sections.file_scores,
5371 coverage_gaps: sections.coverage_gaps,
5372 config_activates_coverage_gaps: !sections.any_section,
5373 hotspots: sections.hotspots,
5374 ownership: run.ownership,
5375 ownership_emails: run.ownership_emails,
5376 targets: sections.targets,
5377 css: sections.css,
5378 css_deep: false,
5379 force_full: sections.force_full,
5380 score_only_output: sections.score_only_output,
5381 enforce_coverage_gap_gate: true,
5382 effort: run.effort,
5383 score: sections.score,
5384 gates: run.gates,
5385 since: run.since,
5386 min_commits: run.min_commits,
5387 explain: cli.explain,
5388 summary: cli.summary,
5389 save_snapshot: args
5390 .save_snapshot
5391 .map(|opt| PathBuf::from(opt.as_deref().unwrap_or_default())),
5392 trend: args.trend,
5393 coverage_inputs: run.coverage_inputs,
5394 performance: cli.performance,
5395 runtime_coverage: run.runtime_coverage,
5396 churn_file: cli.churn_file.as_deref(),
5397 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
5398 complexity_breakdown: args.complexity_breakdown,
5399 group_by: cli.group_by.map(Into::into),
5400 },
5401 dispatch.json_style,
5402 &health::TypeAwareHealthOptions {
5403 enabled: cli.type_aware,
5404 requested: args.type_coupling,
5405 unfiltered: health_type_coupling_is_default_section(args),
5406 projects: &cli.type_aware_project,
5407 require: cli.type_aware_require.map(Into::into),
5408 },
5409 )
5410}
5411
5412fn health_type_coupling_is_default_section(args: &HealthDispatchArgs<'_>) -> bool {
5413 !args.complexity
5414 && !args.file_scores
5415 && !args.coverage_gaps
5416 && !args.hotspots
5417 && !args.ownership
5418 && !args.targets
5419 && !args.css
5420 && !args.score
5421 && args.min_score.is_none()
5422 && args.min_severity.is_none()
5423 && args.runtime_coverage.is_none()
5424}
5425
5426#[cfg(test)]
5427mod tests {
5428 use super::*;
5429
5430 #[test]
5434 fn cli_definition_has_no_flag_collisions() {
5435 use clap::CommandFactory;
5436 Cli::command().debug_assert();
5437 }
5438
5439 #[test]
5440 fn impact_statusline_subcommand_parses() {
5441 use clap::Parser;
5442
5443 let cli = Cli::try_parse_from(["fallow", "impact", "statusline"]).expect("argv parses");
5444 assert!(matches!(
5445 cli.command,
5446 Some(Command::Impact {
5447 subcommand: Some(ImpactCli::Statusline),
5448 ..
5449 })
5450 ));
5451 }
5452
5453 #[test]
5454 fn impact_statusline_bypasses_command_epilogue() {
5455 use clap::Parser;
5456
5457 let statusline =
5458 Cli::try_parse_from(["fallow", "impact", "statusline"]).expect("argv parses");
5459 assert!(is_impact_statusline(&statusline));
5460
5461 let status = Cli::try_parse_from(["fallow", "impact", "status"]).expect("argv parses");
5462 assert!(!is_impact_statusline(&status));
5463
5464 let all_statusline =
5465 Cli::try_parse_from(["fallow", "impact", "--all", "statusline"]).expect("argv parses");
5466 assert!(!is_impact_statusline(&all_statusline));
5467 }
5468
5469 #[test]
5470 fn regression_baseline_help_explains_the_default_destination() {
5471 use clap::CommandFactory;
5472 let help = Cli::command().render_long_help().to_string();
5473
5474 assert!(help.contains("Omit PATH to update regression.baseline"));
5475 assert!(help.contains("discovered fallow config"));
5476 assert!(help.contains("create .fallowrc.json when none exists"));
5477 }
5478
5479 #[test]
5483 fn after_help_lists_every_task_matrix_command() {
5484 for row in crate::task_matrix::TASK_MATRIX {
5485 assert!(
5486 TOP_LEVEL_AFTER_HELP.contains(row.command),
5487 "root --help cheat sheet is missing task-matrix command '{}'; \
5488 update TOP_LEVEL_AFTER_HELP to match TASK_MATRIX",
5489 row.command
5490 );
5491 }
5492 }
5493
5494 #[test]
5498 fn high_value_commands_route_to_distinct_workflows() {
5499 use clap::Parser;
5500 use fallow_config::OutputFormat;
5501
5502 let distinct = [
5503 (vec!["fallow", "impact"], telemetry::Workflow::Impact),
5504 (vec!["fallow", "security"], telemetry::Workflow::Security),
5505 (vec!["fallow", "fix"], telemetry::Workflow::Fix),
5506 (
5507 vec!["fallow", "explain", "unused-exports"],
5508 telemetry::Workflow::Explain,
5509 ),
5510 (
5511 vec!["fallow", "watch"],
5512 telemetry::Workflow::CodeQualityReview,
5513 ),
5514 (
5515 vec!["fallow", "list"],
5516 telemetry::Workflow::ProjectInventory,
5517 ),
5518 (
5519 vec!["fallow", "workspaces"],
5520 telemetry::Workflow::ProjectInventory,
5521 ),
5522 (
5523 vec!["fallow", "schema"],
5524 telemetry::Workflow::ProjectInventory,
5525 ),
5526 (vec!["fallow", "init"], telemetry::Workflow::Setup),
5527 (
5528 vec!["fallow", "hooks", "install", "--target", "git"],
5529 telemetry::Workflow::Setup,
5530 ),
5531 (vec!["fallow", "config-schema"], telemetry::Workflow::Setup),
5532 (vec!["fallow", "plugin-schema"], telemetry::Workflow::Setup),
5533 (
5534 vec!["fallow", "rule-pack-schema"],
5535 telemetry::Workflow::Setup,
5536 ),
5537 (vec!["fallow", "config"], telemetry::Workflow::Setup),
5538 (
5539 vec!["fallow", "ci-template", "gitlab"],
5540 telemetry::Workflow::Setup,
5541 ),
5542 (vec!["fallow", "migrate"], telemetry::Workflow::Setup),
5543 (
5544 vec!["fallow", "telemetry", "status"],
5545 telemetry::Workflow::Setup,
5546 ),
5547 (vec!["fallow", "setup-hooks"], telemetry::Workflow::Setup),
5548 (
5549 vec!["fallow", "audit-cache", "remove", "--root", "."],
5550 telemetry::Workflow::Setup,
5551 ),
5552 (
5553 vec!["fallow", "license", "status"],
5554 telemetry::Workflow::License,
5555 ),
5556 ];
5557 for (argv, expected) in distinct {
5558 let cli = Cli::try_parse_from(&argv).expect("argv parses");
5559 assert_eq!(
5560 telemetry_workflow_for_command(cli.command.as_ref(), OutputFormat::Json),
5561 expected,
5562 "{argv:?} should map to {expected:?}"
5563 );
5564 }
5565 }
5566
5567 #[test]
5572 fn version_flag_accepts_lower_v_upper_v_and_long() {
5573 use clap::CommandFactory;
5574 for argv in [["fallow", "-v"], ["fallow", "-V"], ["fallow", "--version"]] {
5575 let err = Cli::command()
5576 .try_get_matches_from(argv)
5577 .expect_err("version flag should short-circuit parsing");
5578 assert_eq!(
5579 err.kind(),
5580 clap::error::ErrorKind::DisplayVersion,
5581 "{argv:?} should trigger the Version action"
5582 );
5583 }
5584 }
5585
5586 #[test]
5591 fn cli_help_text_contains_no_implementation_status_wording() {
5592 use clap::CommandFactory;
5593 let mut root = Cli::command();
5594 let mut violations: Vec<(String, String)> = Vec::new();
5595 visit_help(&mut root, "fallow", &mut violations);
5596 assert!(
5597 violations.is_empty(),
5598 "found implementation-status wording in --help output:\n{}",
5599 violations
5600 .iter()
5601 .map(|(cmd, line)| format!(" {cmd}: {line}"))
5602 .collect::<Vec<_>>()
5603 .join("\n")
5604 );
5605 }
5606
5607 #[test]
5608 fn top_level_help_groups_commands_by_workflow() {
5609 use clap::CommandFactory;
5610 let help = Cli::command().render_long_help().to_string();
5611 let expected_order = [
5612 "Analysis:",
5613 " dead-code",
5614 " dupes",
5615 " health",
5616 " flags",
5617 " security",
5618 " audit",
5619 "Workflow:",
5620 " watch",
5621 " fix",
5622 "Project inspection:",
5623 " list",
5624 " workspaces",
5625 " explain",
5626 " impact",
5627 " viz",
5628 "Setup and configuration:",
5629 " init",
5630 " recommend",
5631 " migrate",
5632 " config",
5633 " config-schema",
5634 " plugin-schema",
5635 " plugin-check",
5636 " rule-pack-schema",
5637 "Automation and CI:",
5638 " ci",
5639 " ci-template",
5640 " hooks",
5641 " setup-hooks",
5642 "Runtime coverage:",
5643 " coverage",
5644 " license",
5645 "Reference:",
5646 " schema",
5647 " help",
5648 "Options:",
5649 ];
5650 let mut cursor = 0;
5651 for needle in expected_order {
5652 let Some(offset) = help[cursor..].find(needle) else {
5653 panic!("top-level help missing `{needle}` after byte {cursor}:\n{help}");
5654 };
5655 cursor += offset + needle.len();
5656 }
5657 }
5658
5659 #[test]
5660 fn security_help_hides_globals_rejected_by_security_validator() {
5661 let help = render_security_help(SecurityHelpTarget::Parent);
5662
5663 for long in SECURITY_UNSUPPORTED_GLOBAL_LONGS {
5664 assert!(
5665 !help_contains_long_flag(&help, long),
5666 "security help must hide unsupported --{long}:\n{help}"
5667 );
5668 }
5669
5670 for long in [
5671 "root",
5672 "config",
5673 "format",
5674 "quiet",
5675 "no-cache",
5676 "threads",
5677 "changed-since",
5678 "diff-file",
5679 "diff-stdin",
5680 "workspace",
5681 "changed-workspaces",
5682 "ci",
5683 "fail-on-issues",
5684 "sarif-file",
5685 "summary",
5686 "output-file",
5687 "max-file-size",
5688 "explain",
5689 "surface",
5690 ] {
5691 assert!(
5692 help_contains_long_flag(&help, long),
5693 "security help must keep supported --{long}:\n{help}"
5694 );
5695 }
5696 }
5697
5698 #[test]
5699 fn security_help_detection_covers_subcommand_and_help_alias_forms() {
5700 assert_eq!(
5701 security_help_target(["security", "--help"]),
5702 Some(SecurityHelpTarget::Parent)
5703 );
5704 assert_eq!(
5705 security_help_target(["security", "-h"]),
5706 Some(SecurityHelpTarget::Parent)
5707 );
5708 assert_eq!(
5709 security_help_target(["--format", "json", "security", "--help"]),
5710 Some(SecurityHelpTarget::Parent)
5711 );
5712 assert_eq!(
5713 security_help_target(["help", "security"]),
5714 Some(SecurityHelpTarget::Parent)
5715 );
5716 assert_eq!(
5717 security_help_target(["security", "survivors", "--help"]),
5718 Some(SecurityHelpTarget::Survivors)
5719 );
5720 assert_eq!(
5721 security_help_target(["security", "survivors", "-h"]),
5722 Some(SecurityHelpTarget::Survivors)
5723 );
5724 assert_eq!(
5725 security_help_target(["help", "security", "survivors"]),
5726 Some(SecurityHelpTarget::Survivors)
5727 );
5728 assert_eq!(
5729 security_help_target(["security", "blind-spots", "--help"]),
5730 Some(SecurityHelpTarget::BlindSpots)
5731 );
5732 assert_eq!(
5733 security_help_target(["help", "security", "blind-spots"]),
5734 Some(SecurityHelpTarget::BlindSpots)
5735 );
5736 assert_eq!(security_help_target(["health", "--help"]), None);
5737 assert_eq!(security_help_target(["help", "health"]), None);
5738 }
5739
5740 #[test]
5741 fn security_unsupported_global_validator_matches_hidden_help_contract() {
5742 for (argv, expected) in [
5743 (vec!["fallow", "security", "--performance"], "--performance"),
5744 (
5745 vec!["fallow", "security", "--baseline", "base.json"],
5746 "--baseline",
5747 ),
5748 (
5749 vec!["fallow", "security", "--dupes-mode", "weak"],
5750 "--dupes-mode",
5751 ),
5752 ] {
5753 let cli = Cli::try_parse_from(argv).expect("security global parses before validation");
5754 assert_eq!(unsupported_security_global(&cli), Some(expected));
5755 }
5756
5757 let explain = Cli::try_parse_from(["fallow", "security", "--explain"])
5758 .expect("security --explain parses");
5759 assert_eq!(unsupported_security_global(&explain), None);
5760 }
5761
5762 #[test]
5763 fn programmatic_common_options_track_analysis_affecting_cli_globals() {
5764 use clap::CommandFactory;
5765
5766 let cli_flags: std::collections::BTreeSet<String> = Cli::command()
5767 .get_arguments()
5768 .filter(|arg| arg.is_global_set())
5769 .filter_map(|arg| arg.get_long().map(str::to_owned))
5770 .filter(|name| {
5771 matches!(
5772 name.as_str(),
5773 "root"
5774 | "config"
5775 | "allow-remote-extends"
5776 | "no-cache"
5777 | "threads"
5778 | "changed-since"
5779 | "diff-file"
5780 | "production"
5781 | "workspace"
5782 | "changed-workspaces"
5783 | "explain"
5784 )
5785 })
5786 .collect();
5787 let programmatic_flags: std::collections::BTreeSet<String> =
5788 fallow_api::COMMON_ANALYSIS_OPTION_FLAGS
5789 .iter()
5790 .map(|flag| (*flag).to_owned())
5791 .collect();
5792
5793 assert_eq!(programmatic_flags, cli_flags);
5794 }
5795
5796 #[test]
5797 fn dead_code_registry_filter_flags_are_exposed_by_clap() {
5798 use clap::CommandFactory;
5799
5800 let cli = Cli::command();
5801 let dead_code = cli
5802 .get_subcommands()
5803 .find(|command| command.get_name() == "dead-code")
5804 .expect("dead-code subcommand is registered");
5805 let cli_flags: std::collections::BTreeSet<String> = dead_code
5806 .get_arguments()
5807 .filter_map(|arg| arg.get_long().map(|long| format!("--{long}")))
5808 .collect();
5809
5810 for flag in fallow_types::issue_meta::DEAD_CODE_FILTER_FLAGS.iter() {
5811 assert!(
5812 cli_flags.contains(*flag),
5813 "registry filter flag {flag} is missing from dead-code clap args"
5814 );
5815 }
5816 }
5817
5818 fn help_contains_long_flag(help: &str, long: &str) -> bool {
5819 let flag = format!("--{long}");
5820 help.split(|c: char| c.is_whitespace() || c == ',' || c == '[' || c == ']')
5821 .any(|token| token == flag)
5822 }
5823
5824 fn visit_help(cmd: &mut clap::Command, path: &str, violations: &mut Vec<(String, String)>) {
5825 let help = cmd.render_long_help().to_string();
5826 for line in scan_forbidden(&help) {
5827 violations.push((path.to_owned(), line));
5828 }
5829 let names: Vec<String> = cmd
5830 .get_subcommands()
5831 .map(|sub| sub.get_name().to_owned())
5832 .collect();
5833 for name in names {
5834 if name == "help" {
5835 continue;
5836 }
5837 if let Some(sub) = cmd.find_subcommand_mut(&name) {
5838 let sub_path = format!("{path} {name}");
5839 visit_help(sub, &sub_path, violations);
5840 }
5841 }
5842 }
5843
5844 fn scan_forbidden(s: &str) -> Vec<String> {
5845 let lower = s.to_ascii_lowercase();
5846 let mut out = Vec::new();
5847 for word in ["stub", "placeholder"] {
5848 if let Some(idx) = find_whole_word(&lower, word) {
5849 out.push(extract_line(s, idx));
5850 }
5851 }
5852 if let Some(idx) = lower.find("not yet") {
5853 out.push(extract_line(s, idx));
5854 }
5855 out
5856 }
5857
5858 fn find_whole_word(haystack: &str, word: &str) -> Option<usize> {
5859 let bytes = haystack.as_bytes();
5860 let mut start = 0;
5861 while let Some(rel) = haystack[start..].find(word) {
5862 let abs = start + rel;
5863 let before_ok = abs == 0 || !bytes[abs - 1].is_ascii_alphanumeric();
5864 let after_idx = abs + word.len();
5865 let after_ok = after_idx >= bytes.len() || !bytes[after_idx].is_ascii_alphanumeric();
5866 if before_ok && after_ok {
5867 return Some(abs);
5868 }
5869 start = abs + word.len();
5870 }
5871 None
5872 }
5873
5874 fn extract_line(s: &str, byte_idx: usize) -> String {
5875 let line_start = s[..byte_idx].rfind('\n').map_or(0, |i| i + 1);
5876 let line_end = s[byte_idx..].find('\n').map_or(s.len(), |i| byte_idx + i);
5877 s[line_start..line_end].trim().to_owned()
5878 }
5879
5880 #[test]
5881 fn emit_error_returns_given_exit_code() {
5882 let code = emit_error("test error", 2, fallow_config::OutputFormat::Human);
5883 assert_eq!(code, ExitCode::from(2));
5884 }
5885
5886 fn telemetry_run_for_mode(mode: telemetry::AnalysisMode) -> TelemetryRun {
5887 TelemetryRun {
5888 workflow: telemetry::Workflow::Health,
5889 output: fallow_config::OutputFormat::Json,
5890 quiet: true,
5891 start: std::time::Instant::now(),
5892 context: telemetry::WorkflowContext {
5893 run_scope: telemetry::RunScope::FullProject,
5894 config_shape: telemetry::ConfigShape::Default,
5895 output_destination: telemetry::OutputDestination::Stdout,
5896 analysis_mode: mode,
5897 },
5898 }
5899 }
5900
5901 #[test]
5902 fn fallback_failure_reason_skips_success_and_findings() {
5903 let run = telemetry_run_for_mode(telemetry::AnalysisMode::Static);
5904
5905 assert_eq!(fallback_failure_reason_for(&run, ExitCode::SUCCESS), None);
5906 assert_eq!(fallback_failure_reason_for(&run, ExitCode::from(1)), None);
5907 }
5908
5909 #[test]
5910 fn fallback_failure_reason_classifies_network_auth_and_analysis() {
5911 let static_run = telemetry_run_for_mode(telemetry::AnalysisMode::Static);
5912 let cloud_run = telemetry_run_for_mode(telemetry::AnalysisMode::ProductionCoverage);
5913
5914 assert_eq!(
5915 fallback_failure_reason_for(&static_run, ExitCode::from(api::NETWORK_EXIT_CODE)),
5916 Some(telemetry::FailureReason::Network),
5917 );
5918 assert_eq!(
5919 fallback_failure_reason_for(&static_run, ExitCode::from(12)),
5920 Some(telemetry::FailureReason::Auth),
5921 );
5922 assert_eq!(
5923 fallback_failure_reason_for(&cloud_run, ExitCode::from(3)),
5924 Some(telemetry::FailureReason::Auth),
5925 );
5926 assert_eq!(
5927 fallback_failure_reason_for(&static_run, ExitCode::from(2)),
5928 Some(telemetry::FailureReason::Analysis),
5929 );
5930 }
5931
5932 #[test]
5933 fn bare_coverage_flags_parse_without_subcommand() {
5934 let cli = Cli::try_parse_from([
5935 "fallow",
5936 "--coverage",
5937 "coverage/coverage-final.json",
5938 "--coverage-root",
5939 "/ci/workspace",
5940 ])
5941 .expect("bare combined coverage flags should parse");
5942 assert!(cli.command.is_none());
5943 assert_eq!(
5944 cli.coverage.as_deref(),
5945 Some(std::path::Path::new("coverage/coverage-final.json"))
5946 );
5947 assert_eq!(
5948 cli.coverage_root.as_deref(),
5949 Some(std::path::Path::new("/ci/workspace"))
5950 );
5951 }
5952
5953 #[test]
5954 fn bare_coverage_before_subcommand_is_detectable() {
5955 let cli = Cli::try_parse_from([
5956 "fallow",
5957 "--coverage",
5958 "coverage/coverage-final.json",
5959 "dead-code",
5960 ])
5961 .expect("clap should parse pre-subcommand bare coverage for custom rejection");
5962 assert!(cli.command.is_some());
5963 assert!(cli_has_bare_coverage_input(&cli));
5964 let message = bare_coverage_subcommand_error_message();
5965 assert!(message.contains("bare combined-mode flags"));
5966 assert!(message.contains("fallow health --coverage <coverage-final.json>"));
5967 }
5968
5969 #[test]
5970 fn subcommand_coverage_flag_keeps_regular_clap_error() {
5971 let Err(err) = Cli::try_parse_from(["fallow", "dead-code", "--coverage"]) else {
5972 panic!("dead-code --coverage should fail to parse");
5973 };
5974 assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
5975 }
5976
5977 #[test]
5978 fn type_aware_flags_parse_for_semantic_analysis() {
5979 let cli = Cli::try_parse_from([
5980 "fallow",
5981 "dead-code",
5982 "--unused-class-members",
5983 "--type-aware",
5984 "--type-aware-project",
5985 "tsconfig.json",
5986 "--type-aware-project",
5987 "packages/web/tsconfig.json",
5988 ])
5989 .expect("type-aware flag should parse");
5990 assert!(cli.type_aware);
5991 assert_eq!(
5992 cli.type_aware_project,
5993 [
5994 PathBuf::from("tsconfig.json"),
5995 PathBuf::from("packages/web/tsconfig.json")
5996 ]
5997 );
5998 let Some(Command::Check {
5999 unused_class_members,
6000 ..
6001 }) = cli.command
6002 else {
6003 panic!("dead-code should parse as the check command");
6004 };
6005 assert!(unused_class_members);
6006 }
6007
6008 #[test]
6009 fn type_aware_status_output_hides_host_paths() {
6010 let root = Path::new("/private/work/project");
6011 let output = type_aware_status_output(
6012 root,
6013 fallow_api::TypeAwareStatus {
6014 available: false,
6015 discovery_source: Some("environment-override"),
6016 companion_path: Some(PathBuf::from("/private/tools/fallow-type-aware")),
6017 package_version: None,
6018 protocol_version: 6,
6019 backend_family: None,
6020 backend_version: None,
6021 remediation: Some(
6022 "failed to launch /private/tools/fallow-type-aware from /private/work/project"
6023 .to_string(),
6024 ),
6025 },
6026 );
6027
6028 assert_eq!(output.companion_path.as_deref(), Some("fallow-type-aware"));
6029 let remediation = output.remediation.expect("remediation");
6030 assert!(!remediation.contains("/private/"));
6031 assert!(remediation.contains("fallow-type-aware"));
6032 }
6033
6034 #[test]
6035 fn format_parsing_covers_all_variants() {
6036 assert!(matches!(parse_format_arg("json"), Some(Format::Json)));
6037 assert!(matches!(parse_format_arg("JSON"), Some(Format::Json)));
6038 assert!(matches!(parse_format_arg("human"), Some(Format::Human)));
6039 assert!(matches!(parse_format_arg("sarif"), Some(Format::Sarif)));
6040 assert!(matches!(parse_format_arg("compact"), Some(Format::Compact)));
6041 assert!(matches!(
6042 parse_format_arg("markdown"),
6043 Some(Format::Markdown)
6044 ));
6045 assert!(matches!(parse_format_arg("md"), Some(Format::Markdown)));
6046 assert!(matches!(
6047 parse_format_arg("codeclimate"),
6048 Some(Format::CodeClimate)
6049 ));
6050 assert!(matches!(
6051 parse_format_arg("gitlab-codequality"),
6052 Some(Format::CodeClimate)
6053 ));
6054 assert!(matches!(
6055 parse_format_arg("gitlab-code-quality"),
6056 Some(Format::CodeClimate)
6057 ));
6058 assert!(matches!(
6059 parse_format_arg("pr-comment-github"),
6060 Some(Format::PrCommentGithub)
6061 ));
6062 assert!(matches!(
6063 parse_format_arg("pr-comment-gitlab"),
6064 Some(Format::PrCommentGitlab)
6065 ));
6066 assert!(matches!(
6067 parse_format_arg("review-github"),
6068 Some(Format::ReviewGithub)
6069 ));
6070 assert!(matches!(
6071 parse_format_arg("review-gitlab"),
6072 Some(Format::ReviewGitlab)
6073 ));
6074 assert!(matches!(parse_format_arg("badge"), Some(Format::Badge)));
6075 assert!(parse_format_arg("xml").is_none());
6076 assert!(parse_format_arg("").is_none());
6077 }
6078
6079 #[test]
6080 fn quiet_parsing_logic() {
6081 let parse = |s: &str| -> bool { s == "1" || s.eq_ignore_ascii_case("true") };
6082 assert!(parse("1"));
6083 assert!(parse("true"));
6084 assert!(parse("TRUE"));
6085 assert!(parse("True"));
6086 assert!(!parse("0"));
6087 assert!(!parse("false"));
6088 assert!(!parse("yes"));
6089 }
6090
6091 #[test]
6092 fn tracing_filter_defaults_to_warn_without_env() {
6093 assert_eq!(build_tracing_filter(None).to_string(), "warn");
6094 }
6095
6096 #[test]
6097 fn tracing_filter_respects_explicit_env_directives() {
6098 assert_eq!(build_tracing_filter(Some("info")).to_string(), "info");
6099 }
6100
6101 #[test]
6102 fn tracing_filter_treats_empty_env_as_off() {
6103 assert_eq!(build_tracing_filter(Some("")).to_string(), "off");
6104 assert_eq!(build_tracing_filter(Some(" ")).to_string(), "off");
6105 }
6106}