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