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