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