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