1#![expect(
2 clippy::print_stdout,
3 clippy::print_stderr,
4 reason = "CLI binary produces intentional terminal output"
5)]
6#![cfg_attr(
7 test,
8 allow(
9 clippy::unwrap_used,
10 clippy::expect_used,
11 reason = "tests use unwrap and expect to keep fixture setup concise"
12 )
13)]
14
15use std::io::IsTerminal as _;
16use std::path::{Path, PathBuf};
17use std::process::ExitCode;
18
19use clap::{Parser, Subcommand};
20
21mod api;
22#[cfg(test)]
23mod architecture_boundaries;
24mod audit;
25mod audit_brief;
26mod audit_cache_prune;
27mod audit_decision_surface;
28mod audit_focus;
29mod audit_walkthrough;
30mod base_worktree;
31pub use base_worktree::canonical_root_hash;
35mod walkthrough_state;
36use fallow_engine::baseline;
37mod agent_install;
38mod baseline_gate;
39mod cache_notice;
40mod check;
41mod ci;
42mod ci_template;
43mod cli_agent;
44mod cli_format;
45mod cli_hooks;
46mod cli_impact;
47mod cli_production;
48mod cli_report;
49mod cli_startup;
50pub use fallow_engine::codeowners;
51mod combined;
52mod config;
53mod coverage;
54mod discovery_note;
55mod doctor;
56mod dupes;
57mod exit_codes;
58pub mod explain;
59mod fix;
60mod flags;
61mod gates;
62mod guard;
63mod health;
64mod impact;
65mod init;
66mod inspect;
67mod json_style;
68mod license;
69mod list;
70mod migrate;
71mod onboarding;
72#[cfg(test)]
73mod output_envelope;
74mod output_runtime;
75mod path_util;
76mod plugin_check;
77mod rayon_pool;
78mod regression;
79pub mod report;
80mod requests;
81mod rule_pack;
82mod runtime_support;
83mod schema;
84mod scope_path;
85mod security;
86mod security_help;
87mod selector;
88mod setup_hooks;
89mod signal;
90mod similar_code_cli;
91mod similar_code_help;
92mod suppressions;
93mod task_matrix;
94mod telemetry;
95mod trace_chain;
96mod trace_error;
97mod trace_path;
98mod type_aware_degrade;
99mod update_check;
100use fallow_engine::validate;
101use fallow_engine::vital_signs;
102mod cli_telemetry;
103mod viz;
104mod watch;
105
106use check::{CheckOptions, IssueFilters, TraceOptions};
107pub(crate) mod error;
109use cli_agent::{AgentCli, run_agent_command};
110#[cfg(test)]
111use cli_format::parse_format_arg;
112use cli_format::{Format, FormatConfig};
113use cli_hooks::{HooksCli, run_hooks_command};
114use cli_impact::{ImpactCli, ImpactCrossRepoOpts, ImpactSortCli, dispatch_impact};
115use cli_production::{ProductionModes, resolve_production_modes};
116#[cfg(test)]
117use cli_startup::build_tracing_filter;
118use cli_startup::{
119 bare_coverage_subcommand_error_message, cli_has_bare_coverage_input, parse_cli_args,
120 run_pre_dispatch_checks, setup_tracing, validate_inputs,
121};
122#[cfg(test)]
123use cli_telemetry::TelemetryRun;
124#[cfg(test)]
125use cli_telemetry::{fallback_failure_reason_for, telemetry_workflow_for_command};
126use cli_telemetry::{record_run_epilogue, start_telemetry_run};
127use dupes::{DupesMode, DupesOptions};
128use error::emit_error;
129use health::{HealthOptions, SortBy};
130use list::ListOptions;
131pub(crate) use runtime_support::{AnalysisKind, GroupBy};
132pub(crate) use runtime_support::{
133 ConfigLoadOptions, LoadConfigArgs, build_ownership_resolver, load_config,
134 load_config_for_analysis,
135};
136#[cfg(test)]
137use security_help::{SECURITY_UNSUPPORTED_GLOBAL_LONGS, SecurityHelpTarget};
138use security_help::{render_security_help, security_help_target};
139use similar_code_help::{render_similar_code_help, similar_code_help_target};
140
141const DEFAULT_MIN_INVOCATIONS_HOT: u64 = 100;
142
143const TOP_LEVEL_HELP_TEMPLATE: &str =
144 "{about-with-newline}\n{usage-heading} {usage}{after-help}\n\nOptions:\n{options}";
145
146macro_rules! top_level_task_cheat_sheet {
149 () => {
150 "\
151When the agent is about to...
152 delete an \"unused\" export or file fallow dead-code --trace <file>:<export>
153 prove exact TypeScript symbol consumers fallow dead-code --type-aware --symbol-impact <file>:<export-or-class.method>
154 find how one module reaches another fallow trace --path <from> <to>
155 delete an \"unused\" dependency fallow dead-code --trace-dependency <name>
156 commit or open a PR fallow audit --base <ref>
157 read a diff before approving it fallow review --base <ref> --brief
158 prioritize refactoring fallow health --hotspots --targets
159 ask who owns code fallow health --ownership
160 check untested-but-reachable code fallow health --coverage-gaps
161 consolidate duplication fallow dupes --trace dup:<fingerprint>
162 find feature flags fallow flags
163 check architecture rules before editing fallow guard <files>
164 surface security candidates fallow security
165 inspect a target before editing fallow inspect --file <path>
166 understand a finding fallow explain <issue-type>
167 scope a monorepo --workspace <glob> / --changed-workspaces <ref>"
168 };
169}
170
171macro_rules! top_level_core_command_groups {
172 () => {
173 "\
174Analysis:
175 dead-code Analyze unused code, dependency hygiene, and architecture cycles
176 dupes Find copy-paste and structural code duplication
177 health Analyze complexity, maintainability, hotspots, and coverage gaps
178 flags Detect feature flag usage patterns
179 security Surface local security candidates for agent verification (opt-in)
180 similar-code Find semantic implementation overlap for verification (opt-in, local)
181 audit Review changed files for dead code, complexity, duplication, and styling
182
183Workflow:
184 watch Re-run analysis as files change
185 fix Auto-fix safe unused-code findings"
186 };
187}
188
189macro_rules! top_level_extended_command_groups {
190 () => {
191 "\
192Project inspection:
193 list List discovered files, entry points, plugins, boundaries, and workspaces
194 inspect Inspect one file or exported symbol as a bundled evidence query
195 trace Trace a symbol's call chain (best-effort, syntactic)
196 trace-error Resolve a runtime stack trace's frames to project definitions
197 guard Show which architecture rules apply to files before editing
198 decision-surface Surface the structural decisions a change embeds (advisory)
199 workspaces Show monorepo workspace discovery diagnostics
200 explain Explain one issue type without running analysis
201 suppressions List active fallow-ignore suppression markers
202 impact Show what fallow has done for you (opt-in, local-only)
203 viz Generate an interactive HTML map of the codebase
204
205Setup and configuration:
206 doctor Diagnose project readiness without changing anything
207 init Create a fallow config, optionally with a Git hook
208 agent Wire fallow into Claude Code, Codex, or Cursor in one pass
209 audit-cache Maintain reusable audit base-snapshot caches
210 recommend Recommend a project-tailored config for an agent to author
211 migrate Migrate knip, jscpd, or stylelint config to fallow
212 config Show the resolved config and loaded config file
213 config-schema Print the fallow config JSON Schema
214 plugin-schema Print the external plugin JSON Schema
215 plugin-check Dry-run external plugins and report what they seed
216 rule-pack Manage declarative rule packs (policy-as-code)
217 rule-pack-schema Print the rule pack JSON Schema
218 type-aware Inspect the optional TypeScript semantic companion
219
220Automation and CI:
221 ci Build PR/MR feedback envelopes
222 ci-template Print or vendor CI integration templates
223 report Re-render saved JSON as GitHub or CodeClimate output
224 hooks Install or remove fallow-managed Git and agent hooks
225 setup-hooks Deprecated: use `agent install` or `hooks install --target agent`
226
227Runtime coverage:
228 coverage Set up or analyze runtime coverage data
229 license Manage the paid-feature license
230 telemetry Manage opt-in product telemetry
231
232Reference:
233 schema Dump the CLI interface as machine-readable JSON
234 help Print this message or the help of a command"
235 };
236}
237
238const TOP_LEVEL_AFTER_HELP: &str = concat!(
239 top_level_task_cheat_sheet!(),
240 "\n\n",
241 top_level_core_command_groups!(),
242 "\n\nRun fallow --help for the complete command list."
243);
244
245const TOP_LEVEL_AFTER_LONG_HELP: &str = concat!(
246 top_level_task_cheat_sheet!(),
247 "\n\n",
248 top_level_core_command_groups!(),
249 "\n\n",
250 top_level_extended_command_groups!(),
251 "\n\n",
252 "When no command is given, fallow runs dead-code + dupes + health together.\n",
253 "Use --only/--skip to select specific analyses."
254);
255
256#[derive(Parser)]
257#[command(
258 name = "fallow",
259 about = "Codebase analyzer for TypeScript/JavaScript: unused code, circular dependencies, code duplication, complexity hotspots, and architecture boundary violations",
260 version,
261 disable_version_flag = true,
262 help_template = TOP_LEVEL_HELP_TEMPLATE,
263 after_help = TOP_LEVEL_AFTER_HELP,
264 after_long_help = TOP_LEVEL_AFTER_LONG_HELP
265)]
266struct Cli {
267 #[command(subcommand)]
268 command: Option<Command>,
269
270 #[arg(value_name = "PATH")]
273 path: Option<PathBuf>,
274
275 #[arg(
279 short = 'v',
280 visible_short_alias = 'V',
281 long = "version",
282 action = clap::ArgAction::Version
283 )]
284 version: Option<bool>,
285
286 #[arg(short, long, global = true)]
288 root: Option<PathBuf>,
289
290 #[arg(short, long, global = true)]
292 config: Option<PathBuf>,
293
294 #[arg(hide_short_help = true, long, global = true)]
296 allow_remote_extends: bool,
297
298 #[arg(
300 short,
301 long,
302 visible_alias = "output",
303 global = true,
304 default_value = "human"
305 )]
306 format: Format,
307
308 #[arg(hide_short_help = true, long, global = true)]
310 pretty: bool,
311
312 #[arg(short, long, global = true)]
314 quiet: bool,
315
316 #[arg(hide_short_help = true, long, global = true)]
318 no_cache: bool,
319
320 #[arg(hide_short_help = true, long, global = true)]
322 threads: Option<usize>,
323
324 #[arg(long, visible_alias = "base", global = true)]
326 changed_since: Option<String>,
327
328 #[arg(
333 hide_short_help = true,
334 long = "diff-file",
335 value_name = "PATH",
336 global = true
337 )]
338 diff_file: Option<PathBuf>,
339
340 #[arg(hide_short_help = true, long = "diff-stdin", global = true)]
343 diff_stdin: bool,
344
345 #[arg(
352 hide_short_help = true,
353 long = "churn-file",
354 value_name = "PATH",
355 global = true
356 )]
357 churn_file: Option<PathBuf>,
358
359 #[arg(
366 hide_short_help = true,
367 long = "max-file-size",
368 value_name = "MB",
369 global = true
370 )]
371 max_file_size: Option<u32>,
372
373 #[arg(hide_short_help = true, long, global = true)]
375 baseline: Option<PathBuf>,
376
377 #[arg(
392 hide_short_help = true,
393 long = "baseline-mode",
394 value_enum,
395 global = true
396 )]
397 baseline_mode: Option<BaselineModeArg>,
398
399 #[arg(long, global = true, value_name = "RUN_ID", hide = true)]
405 parent_run: Option<String>,
406
407 #[arg(hide_short_help = true, long, global = true)]
409 save_baseline: Option<PathBuf>,
410
411 #[arg(long, global = true)]
414 production: bool,
415
416 #[arg(
420 hide_short_help = true,
421 long = "no-production",
422 global = true,
423 conflicts_with = "production"
424 )]
425 no_production: bool,
426
427 #[arg(hide_short_help = true, long = "production-dead-code")]
429 production_dead_code: bool,
430
431 #[arg(hide_short_help = true, long = "production-health")]
433 production_health: bool,
434
435 #[arg(hide_short_help = true, long = "production-dupes")]
437 production_dupes: bool,
438
439 #[arg(short, long, global = true, value_delimiter = ',')]
443 workspace: Option<Vec<String>>,
444
445 #[arg(long, global = true, value_name = "REF")]
448 changed_workspaces: Option<String>,
449
450 #[arg(hide_short_help = true, long, global = true)]
452 group_by: Option<GroupBy>,
453
454 #[arg(hide_short_help = true, long, global = true)]
456 performance: bool,
457
458 #[arg(hide_short_help = true, long, global = true)]
460 explain: bool,
461
462 #[arg(hide_short_help = true, long, global = true)]
465 explain_skipped: bool,
466
467 #[arg(hide_short_help = true, long, global = true)]
469 summary: bool,
470
471 #[arg(long, global = true)]
473 ci: bool,
474
475 #[arg(hide_short_help = true, long, global = true)]
477 fail_on_issues: bool,
478
479 #[arg(hide_short_help = true, long, global = true, value_name = "PATH")]
481 sarif_file: Option<PathBuf>,
482
483 #[arg(short = 'o', long, global = true, value_name = "PATH")]
487 output_file: Option<PathBuf>,
488
489 #[arg(
499 hide_short_help = true,
500 long = "report-path-prefix",
501 visible_alias = "annotations-path-prefix",
502 global = true,
503 value_name = "PREFIX"
504 )]
505 report_path_prefix: Option<String>,
506
507 #[arg(hide_short_help = true, long, global = true)]
509 fail_on_regression: bool,
510
511 #[arg(hide_short_help = true, long, global = true)]
525 fail_on_stale_baseline: bool,
526
527 #[arg(
529 hide_short_help = true,
530 long,
531 global = true,
532 value_name = "TOLERANCE",
533 default_value = "0"
534 )]
535 tolerance: String,
536
537 #[arg(hide_short_help = true, long, global = true, value_name = "PATH")]
539 regression_baseline: Option<PathBuf>,
540
541 #[expect(
545 clippy::option_option,
546 reason = "clap pattern: None=not passed, Some(None)=flag only (write to config), Some(Some(path))=write to file"
547 )]
548 #[arg(hide_short_help = true, long, global = true, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
549 save_regression_baseline: Option<Option<String>>,
550
551 #[arg(long, value_delimiter = ',')]
553 only: Vec<AnalysisKind>,
554
555 #[arg(long, value_delimiter = ',')]
557 skip: Vec<AnalysisKind>,
558
559 #[arg(hide_short_help = true, long = "dupes-mode", global = true)]
561 dupes_mode: Option<DupesMode>,
562
563 #[arg(hide_short_help = true, long = "dupes-near", global = true)]
565 dupes_near: bool,
566
567 #[arg(hide_short_help = true, long = "dupes-threshold", global = true)]
569 dupes_threshold: Option<f64>,
570
571 #[arg(hide_short_help = true, long = "dupes-min-tokens", global = true)]
573 dupes_min_tokens: Option<usize>,
574
575 #[arg(hide_short_help = true, long = "dupes-min-lines", global = true)]
577 dupes_min_lines: Option<usize>,
578
579 #[arg(hide_short_help = true, long = "dupes-min-occurrences", global = true, value_parser = parse_min_occurrences)]
581 dupes_min_occurrences: Option<usize>,
582
583 #[arg(hide_short_help = true, long = "dupes-skip-local", global = true)]
585 dupes_skip_local: bool,
586
587 #[arg(hide_short_help = true, long = "dupes-cross-language", global = true)]
589 dupes_cross_language: bool,
590
591 #[arg(hide_short_help = true, long = "dupes-ignore-imports", global = true)]
594 dupes_ignore_imports: bool,
595
596 #[arg(
599 hide_short_help = true,
600 long = "dupes-no-ignore-imports",
601 global = true,
602 conflicts_with = "dupes_ignore_imports"
603 )]
604 dupes_no_ignore_imports: bool,
605
606 #[arg(hide_short_help = true, long)]
608 score: bool,
609
610 #[arg(hide_short_help = true, long)]
612 trend: bool,
613
614 #[expect(
617 clippy::option_option,
618 reason = "clap pattern: None=not passed, Some(None)=default path, Some(Some(path))=custom path"
619 )]
620 #[arg(hide_short_help = true, long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
621 save_snapshot: Option<Option<String>>,
622
623 #[arg(hide_short_help = true, long, value_name = "PATH")]
626 coverage: Option<PathBuf>,
627
628 #[arg(hide_short_help = true, long = "coverage-root", value_name = "PATH")]
631 coverage_root: Option<PathBuf>,
632
633 #[arg(hide_short_help = true, long, global = true)]
635 include_entry_exports: bool,
636
637 #[arg(hide_short_help = true, long, global = true)]
640 type_aware: bool,
641
642 #[arg(
645 hide_short_help = true,
646 long,
647 global = true,
648 conflicts_with = "type_aware"
649 )]
650 no_type_aware: bool,
651
652 #[arg(hide_short_help = true, long, global = true, value_name = "PATH", action = clap::ArgAction::Append)]
654 type_aware_project: Vec<PathBuf>,
655
656 #[arg(hide_short_help = true, long, global = true, value_enum)]
658 type_aware_require: Option<TypeAwareRequireArg>,
659}
660
661impl Cli {
662 const fn type_aware_override(&self) -> Option<bool> {
666 if self.no_type_aware {
667 Some(false)
668 } else if self.type_aware {
669 Some(true)
670 } else {
671 None
672 }
673 }
674}
675
676#[derive(Clone, Copy, Subcommand)]
677enum TypeAwareCli {
678 Status,
680}
681
682#[derive(Subcommand)]
683enum Command {
684 #[command(name = "dead-code", alias = "check")]
686 Check {
687 #[arg(long)]
689 unused_files: bool,
690
691 #[arg(long)]
693 unused_exports: bool,
694
695 #[arg(long)]
697 unused_deps: bool,
698
699 #[arg(long)]
701 unused_types: bool,
702
703 #[arg(long)]
705 private_type_leaks: bool,
706
707 #[arg(long)]
709 unused_enum_members: bool,
710
711 #[arg(long)]
713 unused_class_members: bool,
714
715 #[arg(long)]
717 unused_store_members: bool,
718
719 #[arg(long)]
721 unprovided_injects: bool,
722
723 #[arg(long)]
725 unrendered_components: bool,
726
727 #[arg(long)]
729 unused_component_props: bool,
730
731 #[arg(long)]
733 unused_component_emits: bool,
734
735 #[arg(long)]
737 unused_component_inputs: bool,
738
739 #[arg(long)]
741 unused_component_outputs: bool,
742
743 #[arg(long)]
745 unused_svelte_events: bool,
746
747 #[arg(long)]
749 unused_server_actions: bool,
750
751 #[arg(long)]
753 unused_load_data_keys: bool,
754
755 #[arg(long)]
757 unresolved_imports: bool,
758
759 #[arg(long)]
761 unlisted_deps: bool,
762
763 #[arg(long)]
765 duplicate_exports: bool,
766
767 #[arg(long)]
769 circular_deps: bool,
770
771 #[arg(long)]
773 re_export_cycles: bool,
774
775 #[arg(long)]
777 boundary_violations: bool,
778
779 #[arg(long)]
781 policy_violations: bool,
782
783 #[arg(long)]
785 stale_suppressions: bool,
786
787 #[arg(long)]
789 unused_catalog_entries: bool,
790
791 #[arg(long)]
793 empty_catalog_groups: bool,
794
795 #[arg(long)]
797 unresolved_catalog_references: bool,
798
799 #[arg(long)]
801 unused_dependency_overrides: bool,
802
803 #[arg(long)]
805 misconfigured_dependency_overrides: bool,
806
807 #[arg(long)]
809 include_dupes: bool,
810
811 #[arg(long, value_name = "FILE:EXPORT")]
813 trace: Option<String>,
814
815 #[arg(long, value_name = "PATH")]
817 trace_file: Option<String>,
818
819 #[arg(long, value_name = "PACKAGE")]
821 trace_dependency: Option<String>,
822
823 #[arg(long, value_name = "PATH")]
827 impact_closure: Option<String>,
828
829 #[arg(long, value_name = "FILE:EXPORT")]
831 symbol_impact: Option<String>,
832
833 #[arg(long)]
838 top: Option<usize>,
839
840 #[arg(long, value_name = "PATH")]
844 file: Vec<std::path::PathBuf>,
845
846 #[arg(value_name = "PATH")]
849 path: Option<std::path::PathBuf>,
850 },
851
852 Watch {
854 #[arg(long)]
856 no_clear: bool,
857 },
858
859 TypeAware {
861 #[command(subcommand)]
862 subcommand: TypeAwareCli,
863 },
864
865 #[command(override_help = doctor::HELP)]
872 Doctor,
873
874 SimilarCode {
880 #[command(subcommand)]
881 subcommand: Option<similar_code_cli::SimilarCodeSubcommand>,
882 #[arg(long, value_name = "0..1")]
884 threshold: Option<f64>,
885 #[arg(long, value_name = "N")]
887 min_lines: Option<usize>,
888 #[arg(long, value_name = "N")]
890 top: Option<usize>,
891 #[arg(long, value_name = "PATH")]
893 file: Vec<PathBuf>,
894
895 #[arg(value_name = "PATH")]
898 path: Option<PathBuf>,
899 },
900
901 Inspect {
903 #[arg(
905 long,
906 value_name = "PATH",
907 conflicts_with = "symbol",
908 required_unless_present = "symbol"
909 )]
910 file: Option<String>,
911
912 #[arg(long, value_name = "FILE:EXPORT", conflicts_with = "file")]
914 symbol: Option<String>,
915
916 #[arg(long)]
921 symbol_chain: bool,
922
923 #[arg(long)]
926 churn: bool,
927 },
928
929 Trace {
944 #[arg(value_name = "FILE:SYMBOL", required_unless_present = "path")]
947 symbol: Option<String>,
948
949 #[arg(
953 long,
954 num_args = 2,
955 value_names = ["FROM", "TO"],
956 conflicts_with_all = ["symbol", "callers", "callees", "depth"]
957 )]
958 path: Vec<String>,
959
960 #[arg(long)]
963 callers: bool,
964
965 #[arg(long)]
968 callees: bool,
969
970 #[arg(long, value_name = "N")]
973 depth: Option<u32>,
974 },
975
976 #[command(name = "trace-error")]
995 TraceError {
996 #[arg(value_name = "FILE")]
1000 trace_file: Option<String>,
1001 },
1002
1003 Fix {
1018 #[arg(long)]
1020 dry_run: bool,
1021
1022 #[arg(long, alias = "force")]
1024 yes: bool,
1025
1026 #[arg(long)]
1033 no_create_config: bool,
1034
1035 #[arg(value_name = "PATH")]
1039 path: Option<PathBuf>,
1040 },
1041
1042 Init {
1051 #[arg(long)]
1053 toml: bool,
1054
1055 #[arg(long, conflicts_with_all = ["toml", "hooks", "branch"])]
1057 agents: bool,
1058
1059 #[arg(long)]
1063 hooks: bool,
1064
1065 #[arg(long, requires = "hooks")]
1067 branch: Option<String>,
1068
1069 #[arg(long, conflicts_with_all = ["toml", "agents", "hooks", "branch"])]
1073 decline: bool,
1074 },
1075
1076 Hooks {
1083 #[command(subcommand)]
1084 subcommand: HooksCli,
1085 },
1086
1087 Agent {
1093 #[command(subcommand)]
1094 subcommand: AgentCli,
1095 },
1096
1097 Ci {
1099 #[command(subcommand)]
1100 subcommand: CiCli,
1101 },
1102
1103 ConfigSchema,
1105
1106 PluginSchema,
1108
1109 PluginCheck,
1111
1112 RulePackSchema,
1114
1115 RulePack {
1117 #[command(subcommand)]
1118 subcommand: RulePackCli,
1119 },
1120
1121 Guard {
1123 #[arg(required = true, num_args = 1..)]
1125 files: Vec<String>,
1126 },
1127
1128 Config {
1146 #[arg(long)]
1148 path: bool,
1149 },
1150
1151 Recommend,
1159
1160 List {
1162 #[arg(long)]
1164 entry_points: bool,
1165
1166 #[arg(long)]
1168 files: bool,
1169
1170 #[arg(long)]
1172 plugins: bool,
1173
1174 #[arg(long)]
1176 boundaries: bool,
1177
1178 #[arg(long)]
1182 workspaces: bool,
1183
1184 #[arg(value_name = "PATH")]
1187 path: Option<PathBuf>,
1188 },
1189
1190 Workspaces,
1196
1197 Dupes {
1199 #[arg(long)]
1202 mode: Option<DupesMode>,
1203
1204 #[arg(long)]
1206 near: bool,
1207
1208 #[arg(long)]
1211 min_tokens: Option<usize>,
1212
1213 #[arg(long)]
1216 min_lines: Option<usize>,
1217
1218 #[arg(long, value_parser = parse_min_occurrences)]
1223 min_occurrences: Option<usize>,
1224
1225 #[arg(long)]
1228 threshold: Option<f64>,
1229
1230 #[arg(long)]
1232 skip_local: bool,
1233
1234 #[arg(long)]
1236 cross_language: bool,
1237
1238 #[arg(long)]
1242 ignore_imports: bool,
1243
1244 #[arg(long, conflicts_with = "ignore_imports")]
1247 no_ignore_imports: bool,
1248
1249 #[arg(long)]
1259 top: Option<usize>,
1260
1261 #[arg(long)]
1265 no_fragments: bool,
1266
1267 #[arg(long, value_name = "FILE:LINE")]
1269 trace: Option<String>,
1270
1271 #[arg(value_name = "PATH")]
1274 path: Option<PathBuf>,
1275 },
1276
1277 Health {
1283 #[arg(long)]
1285 max_cyclomatic: Option<u16>,
1286
1287 #[arg(long)]
1289 max_cognitive: Option<u16>,
1290
1291 #[arg(long)]
1295 max_crap: Option<f64>,
1296
1297 #[arg(long)]
1299 top: Option<usize>,
1300
1301 #[arg(long, default_value = "cyclomatic")]
1303 sort: SortBy,
1304
1305 #[arg(long)]
1308 complexity: bool,
1309
1310 #[arg(long)]
1317 complexity_breakdown: bool,
1318
1319 #[arg(long)]
1324 file_scores: bool,
1325
1326 #[arg(long)]
1329 coverage_gaps: bool,
1330
1331 #[arg(long)]
1334 hotspots: bool,
1335
1336 #[arg(long)]
1340 ownership: bool,
1341
1342 #[arg(long, value_name = "MODE", value_enum)]
1347 ownership_emails: Option<EmailModeArg>,
1348
1349 #[arg(long)]
1352 targets: bool,
1353
1354 #[arg(long)]
1357 type_coupling: bool,
1358
1359 #[arg(long)]
1364 css: bool,
1365
1366 #[arg(long, value_enum)]
1369 effort: Option<EffortFilter>,
1370
1371 #[arg(long)]
1374 score: bool,
1375
1376 #[arg(long, value_name = "N")]
1385 min_score: Option<f64>,
1386
1387 #[arg(long, value_name = "LEVEL", value_enum)]
1391 min_severity: Option<HealthSeverityCli>,
1392
1393 #[arg(long)]
1397 report_only: bool,
1398
1399 #[arg(long, value_name = "DURATION")]
1402 since: Option<String>,
1403
1404 #[arg(long, value_name = "N")]
1406 min_commits: Option<u32>,
1407
1408 #[expect(
1412 clippy::option_option,
1413 reason = "clap pattern: None=not passed, Some(None)=flag only, Some(Some(path))=with value"
1414 )]
1415 #[arg(long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
1416 save_snapshot: Option<Option<String>>,
1417
1418 #[arg(long)]
1422 trend: bool,
1423
1424 #[arg(long, value_name = "PATH")]
1433 coverage: Option<PathBuf>,
1434
1435 #[arg(long, value_name = "PATH")]
1441 coverage_root: Option<PathBuf>,
1442
1443 #[arg(long, value_name = "PATH")]
1447 runtime_coverage: Option<PathBuf>,
1448
1449 #[arg(long, default_value_t = 100)]
1451 min_invocations_hot: u64,
1452
1453 #[arg(long, value_name = "N")]
1459 min_observation_volume: Option<u32>,
1460
1461 #[arg(long, value_name = "RATIO")]
1466 low_traffic_threshold: Option<f64>,
1467
1468 #[arg(value_name = "PATH")]
1471 path: Option<PathBuf>,
1472 },
1473
1474 Flags {
1481 #[arg(long)]
1483 top: Option<usize>,
1484 },
1485
1486 Suppressions {
1496 #[arg(long, value_name = "PATH")]
1498 file: Vec<std::path::PathBuf>,
1499 },
1500
1501 Explain {
1507 #[arg(required = true, num_args = 1.., value_name = "ISSUE_TYPE")]
1509 issue_type: Vec<String>,
1510 },
1511
1512 #[command(visible_alias = "review")]
1537 Audit {
1538 #[arg(long = "production-dead-code")]
1540 production_dead_code: bool,
1541
1542 #[arg(long = "production-health")]
1544 production_health: bool,
1545
1546 #[arg(long = "production-dupes")]
1548 production_dupes: bool,
1549
1550 #[arg(long)]
1553 dead_code_baseline: Option<PathBuf>,
1554
1555 #[arg(long)]
1558 health_baseline: Option<PathBuf>,
1559
1560 #[arg(long)]
1563 dupes_baseline: Option<PathBuf>,
1564
1565 #[arg(long)]
1569 max_crap: Option<f64>,
1570
1571 #[arg(long, value_name = "PATH")]
1575 coverage: Option<PathBuf>,
1576
1577 #[arg(long, value_name = "PATH")]
1581 coverage_root: Option<PathBuf>,
1582
1583 #[arg(long = "no-css")]
1585 no_css: bool,
1586
1587 #[arg(long)]
1591 css_deep: bool,
1592
1593 #[arg(long = "no-css-deep")]
1595 no_css_deep: bool,
1596
1597 #[arg(long, value_enum)]
1603 gate: Option<AuditGateArg>,
1604
1605 #[arg(long, value_name = "PATH")]
1614 runtime_coverage: Option<PathBuf>,
1615
1616 #[arg(long, default_value_t = 100)]
1619 min_invocations_hot: u64,
1620
1621 #[arg(long, value_name = "MARKER", hide = true)]
1626 gate_marker: Option<String>,
1627
1628 #[arg(long)]
1634 brief: bool,
1635
1636 #[arg(
1641 long,
1642 value_name = "N",
1643 default_value_t = audit_decision_surface::DEFAULT_DECISION_CAP
1644 )]
1645 max_decisions: usize,
1646
1647 #[arg(long, conflicts_with_all = ["walkthrough_file", "walkthrough"])]
1655 walkthrough_guide: bool,
1656
1657 #[arg(long, value_name = "PATH")]
1667 walkthrough_file: Option<PathBuf>,
1668
1669 #[arg(long, conflicts_with_all = ["walkthrough_guide", "walkthrough_file"])]
1675 walkthrough: bool,
1676
1677 #[arg(long, value_name = "PATH")]
1683 mark_viewed: Vec<PathBuf>,
1684
1685 #[arg(long)]
1689 show_cleared: bool,
1690
1691 #[arg(long)]
1697 show_deprioritized: bool,
1698
1699 #[arg(value_name = "PATH")]
1702 path: Option<PathBuf>,
1703 },
1704
1705 AuditCache {
1707 #[command(subcommand)]
1708 subcommand: AuditCacheCli,
1709 },
1710
1711 DecisionSurface {
1725 #[arg(
1728 long,
1729 value_name = "N",
1730 default_value_t = audit_decision_surface::DEFAULT_DECISION_CAP
1731 )]
1732 max_decisions: usize,
1733 },
1734
1735 Impact {
1745 #[command(subcommand)]
1746 subcommand: Option<ImpactCli>,
1747 #[arg(long)]
1751 all: bool,
1752 #[arg(long, value_enum, default_value_t = ImpactSortCli::Recent)]
1754 sort: ImpactSortCli,
1755 #[arg(long)]
1758 limit: Option<usize>,
1759 },
1760
1761 Security {
1792 #[command(subcommand)]
1793 subcommand: Option<SecuritySubcommand>,
1794 #[arg(long, value_name = "PATH")]
1799 runtime_coverage: Option<PathBuf>,
1800 #[arg(long, default_value_t = 100)]
1803 min_invocations_hot: u64,
1804 #[arg(long, value_name = "PATH")]
1808 file: Vec<std::path::PathBuf>,
1809 #[arg(long, value_name = "MODE")]
1815 gate: Option<security::SecurityGateArg>,
1816 #[arg(long)]
1818 surface: bool,
1819
1820 #[arg(value_name = "PATH")]
1823 path: Option<PathBuf>,
1824 },
1825
1826 Report {
1831 #[arg(long, value_name = "PATH")]
1834 from: PathBuf,
1835 },
1836 Schema,
1838
1839 CiTemplate {
1846 #[command(subcommand)]
1847 subcommand: CiTemplateCli,
1848 },
1849
1850 Migrate {
1852 #[arg(long, conflicts_with = "jsonc")]
1854 toml: bool,
1855
1856 #[arg(long)]
1864 jsonc: bool,
1865
1866 #[arg(long)]
1868 dry_run: bool,
1869
1870 #[arg(long, value_name = "PATH")]
1872 from: Option<PathBuf>,
1873 },
1874
1875 License {
1882 #[command(subcommand)]
1883 subcommand: LicenseCli,
1884 },
1885
1886 Telemetry {
1894 #[command(subcommand)]
1895 subcommand: TelemetryCli,
1896 },
1897
1898 Coverage {
1904 #[command(subcommand)]
1905 subcommand: CoverageCli,
1906 },
1907
1908 SetupHooks {
1920 #[arg(long, value_enum)]
1922 agent: Option<setup_hooks::HookAgentArg>,
1923
1924 #[arg(long)]
1926 dry_run: bool,
1927
1928 #[arg(long)]
1931 force: bool,
1932
1933 #[arg(long)]
1935 user: bool,
1936
1937 #[arg(long)]
1939 gitignore_claude: bool,
1940
1941 #[arg(long)]
1945 uninstall: bool,
1946 },
1947
1948 Viz {
1950 #[arg(long = "out", value_name = "PATH")]
1952 output: Option<PathBuf>,
1953
1954 #[arg(long)]
1956 no_open: bool,
1957
1958 #[arg(long = "viz-format", default_value = "html")]
1960 viz_format: viz::VizFormat,
1961 },
1962}
1963
1964#[derive(Subcommand)]
1965enum SecuritySubcommand {
1966 Survivors {
1968 #[arg(long, value_name = "PATH")]
1970 candidates: PathBuf,
1971 #[arg(long, value_name = "PATH")]
1973 verdicts: PathBuf,
1974 #[arg(long)]
1976 require_verdict_for_each_candidate: bool,
1977 },
1978 #[command(name = "blind-spots")]
1980 BlindSpots {
1981 #[arg(long, value_name = "PATH")]
1983 file: Vec<PathBuf>,
1984 },
1985}
1986
1987#[derive(clap::Subcommand)]
1988enum AuditCacheCli {
1989 Remove {
1995 #[arg(long)]
1997 dry_run: bool,
1998
1999 #[arg(long, alias = "force")]
2001 yes: bool,
2002 },
2003
2004 Prune {
2017 #[arg(long)]
2019 dry_run: bool,
2020
2021 #[arg(long, value_name = "N")]
2028 max_age_days: Option<u32>,
2029 },
2030}
2031
2032#[derive(clap::Subcommand)]
2033enum LicenseCli {
2034 Activate {
2039 #[arg(value_name = "JWT")]
2041 jwt: Option<String>,
2042
2043 #[arg(long, value_name = "PATH")]
2045 from_file: Option<PathBuf>,
2046
2047 #[arg(long, conflicts_with_all = ["jwt", "from_file"])]
2049 stdin: bool,
2050
2051 #[arg(long, requires = "email")]
2058 trial: bool,
2059
2060 #[arg(long, value_name = "ADDR")]
2062 email: Option<String>,
2063 },
2064 Status,
2066 Refresh {
2072 #[arg(long, value_name = "KEY")]
2082 api_key: Option<String>,
2083 },
2084 Deactivate,
2086}
2087
2088#[derive(Clone, Copy, clap::Subcommand)]
2089enum TelemetryCli {
2090 Status,
2092 Enable,
2094 Disable,
2096 Inspect {
2098 #[arg(long)]
2100 example: bool,
2101 },
2102}
2103
2104#[derive(clap::Subcommand)]
2105enum CiTemplateCli {
2106 Gitlab {
2108 #[arg(long, value_name = "DIR", num_args = 0..=1, default_missing_value = ".")]
2112 vendor: Option<PathBuf>,
2113
2114 #[arg(long)]
2116 force: bool,
2117 },
2118}
2119
2120#[derive(clap::Subcommand)]
2121enum CoverageCli {
2122 Setup {
2124 #[arg(short = 'y', long)]
2126 yes: bool,
2127
2128 #[arg(long)]
2130 non_interactive: bool,
2131
2132 #[arg(long)]
2134 json: bool,
2135 },
2136 Analyze {
2142 #[arg(long, value_name = "PATH", conflicts_with = "cloud")]
2144 runtime_coverage: Option<PathBuf>,
2145
2146 #[arg(long, visible_alias = "runtime-coverage-cloud")]
2148 cloud: bool,
2149
2150 #[arg(long, value_name = "KEY")]
2152 api_key: Option<String>,
2153
2154 #[arg(long, value_name = "URL")]
2156 api_endpoint: Option<String>,
2157
2158 #[arg(long, value_name = "OWNER/REPO")]
2164 repo: Option<String>,
2165
2166 #[arg(long, value_name = "ID")]
2168 project_id: Option<String>,
2169
2170 #[arg(long, value_name = "DAYS", default_value_t = 30)]
2172 coverage_period: u16,
2173
2174 #[arg(long, value_name = "ENV")]
2176 environment: Option<String>,
2177
2178 #[arg(long, value_name = "SHA")]
2180 commit_sha: Option<String>,
2181
2182 #[arg(long)]
2184 production: bool,
2185
2186 #[arg(long, default_value_t = 100)]
2188 min_invocations_hot: u64,
2189
2190 #[arg(long, value_name = "N")]
2192 min_observation_volume: Option<u32>,
2193
2194 #[arg(long, value_name = "RATIO")]
2196 low_traffic_threshold: Option<f64>,
2197
2198 #[arg(long)]
2200 top: Option<usize>,
2201
2202 #[arg(long)]
2204 blast_radius: bool,
2205
2206 #[arg(long)]
2208 importance: bool,
2209
2210 #[arg(long)]
2212 debug_unmatched: bool,
2213 },
2214 UploadInventory {
2225 #[arg(long, value_name = "KEY")]
2234 api_key: Option<String>,
2235
2236 #[arg(long, value_name = "URL")]
2241 api_endpoint: Option<String>,
2242
2243 #[arg(long, value_name = "PROJECT_ID")]
2248 project_id: Option<String>,
2249
2250 #[arg(long, value_name = "SHA")]
2255 git_sha: Option<String>,
2256
2257 #[arg(long)]
2263 allow_dirty: bool,
2264
2265 #[arg(long, value_name = "GLOB", num_args = 0..)]
2269 exclude_paths: Vec<String>,
2270
2271 #[arg(long, value_name = "PREFIX")]
2284 path_prefix: Option<String>,
2285
2286 #[arg(long)]
2288 dry_run: bool,
2289
2290 #[arg(long)]
2296 with_callers: bool,
2297
2298 #[arg(long)]
2302 ignore_upload_errors: bool,
2303 },
2304 UploadSourceMaps {
2317 #[arg(long, value_name = "PATH", default_value = "dist")]
2319 dir: PathBuf,
2320
2321 #[arg(long, value_name = "GLOB", default_value = "**/*.map")]
2323 include: String,
2324
2325 #[arg(long, value_name = "GLOB", default_value = "**/node_modules/**")]
2329 exclude: Vec<String>,
2330
2331 #[arg(long, value_name = "NAME")]
2335 repo: Option<String>,
2336
2337 #[arg(long, value_name = "SHA")]
2342 git_sha: Option<String>,
2343
2344 #[arg(long, value_name = "URL")]
2346 endpoint: Option<String>,
2347
2348 #[arg(long, value_name = "BOOL", default_value_t = true, action = clap::ArgAction::Set)]
2353 strip_path: bool,
2354
2355 #[arg(long)]
2357 dry_run: bool,
2358
2359 #[arg(long, value_name = "N", default_value_t = 4)]
2361 concurrency: usize,
2362
2363 #[arg(long)]
2365 fail_fast: bool,
2366 },
2367 UploadStaticFindings {
2374 #[arg(long, value_name = "KEY")]
2384 api_key: Option<String>,
2385
2386 #[arg(long, value_name = "URL")]
2391 api_endpoint: Option<String>,
2392
2393 #[arg(long, value_name = "PROJECT_ID")]
2398 project_id: Option<String>,
2399
2400 #[arg(long, value_name = "SHA")]
2405 git_sha: Option<String>,
2406
2407 #[arg(long)]
2413 allow_dirty: bool,
2414
2415 #[arg(long)]
2417 dry_run: bool,
2418
2419 #[arg(long)]
2423 ignore_upload_errors: bool,
2424 },
2425}
2426
2427#[derive(Subcommand)]
2428enum CiCli {
2429 PlanPrComment {
2431 #[arg(long)]
2433 body: PathBuf,
2434
2435 #[arg(long)]
2437 marker_id: String,
2438
2439 #[arg(long)]
2441 clean: bool,
2442
2443 #[arg(long)]
2445 existing_comment_id: Option<String>,
2446
2447 #[arg(long)]
2449 existing_body: Option<PathBuf>,
2450 },
2451
2452 PostPrComment {
2454 #[arg(long, value_enum)]
2456 provider: CiProviderArg,
2457
2458 #[arg(long)]
2460 pr: Option<String>,
2461
2462 #[arg(long)]
2464 mr: Option<String>,
2465
2466 #[arg(long)]
2468 body: PathBuf,
2469
2470 #[arg(long)]
2472 envelope: Option<PathBuf>,
2473
2474 #[arg(long)]
2476 marker_id: String,
2477
2478 #[arg(long)]
2480 clean: bool,
2481
2482 #[arg(long)]
2484 repo: Option<String>,
2485
2486 #[arg(long = "project-id")]
2488 project_id: Option<String>,
2489
2490 #[arg(long = "api-url")]
2492 api_url: Option<String>,
2493
2494 #[arg(long)]
2496 dry_run: bool,
2497 },
2498
2499 PostReview {
2501 #[arg(long, value_enum)]
2503 provider: CiProviderArg,
2504
2505 #[arg(long)]
2507 pr: Option<String>,
2508
2509 #[arg(long)]
2511 mr: Option<String>,
2512
2513 #[arg(long)]
2515 envelope: PathBuf,
2516
2517 #[arg(long)]
2519 repo: Option<String>,
2520
2521 #[arg(long = "project-id")]
2523 project_id: Option<String>,
2524
2525 #[arg(long = "api-url")]
2527 api_url: Option<String>,
2528
2529 #[arg(long)]
2531 dry_run: bool,
2532 },
2533
2534 PostCheckRun {
2536 #[arg(long, value_enum)]
2538 provider: CiProviderArg,
2539
2540 #[arg(long)]
2542 decision: PathBuf,
2543
2544 #[arg(long)]
2546 repo: String,
2547
2548 #[arg(long = "head-sha")]
2550 head_sha: String,
2551
2552 #[arg(long = "api-url")]
2554 api_url: Option<String>,
2555
2556 #[arg(long = "split-gates")]
2558 split_gates: bool,
2559
2560 #[arg(long)]
2562 dry_run: bool,
2563 },
2564
2565 ReconcileReview {
2567 #[arg(long, value_enum)]
2569 provider: CiProviderArg,
2570
2571 #[arg(long)]
2573 pr: Option<String>,
2574
2575 #[arg(long)]
2577 mr: Option<String>,
2578
2579 #[arg(long)]
2581 envelope: PathBuf,
2582
2583 #[arg(long)]
2585 repo: Option<String>,
2586
2587 #[arg(long = "project-id")]
2589 project_id: Option<String>,
2590
2591 #[arg(long = "api-url")]
2593 api_url: Option<String>,
2594
2595 #[arg(long)]
2597 dry_run: bool,
2598 },
2599}
2600
2601#[derive(Subcommand)]
2602enum RulePackCli {
2603 Init {
2605 name: Option<String>,
2607
2608 #[arg(long, default_value = "starter")]
2610 template: String,
2611
2612 #[arg(long, default_value = "rule-packs")]
2614 dir: String,
2615
2616 #[arg(long)]
2618 no_config: bool,
2619 },
2620
2621 List,
2623
2624 Test {
2626 pack: Option<PathBuf>,
2628 },
2629
2630 Schema,
2632}
2633
2634#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
2636pub enum BaselineModeArg {
2637 #[default]
2639 Count,
2640 Identity,
2643}
2644
2645impl From<BaselineModeArg> for fallow_engine::baseline::HealthBaselineMode {
2646 fn from(value: BaselineModeArg) -> Self {
2647 match value {
2648 BaselineModeArg::Count => Self::Count,
2649 BaselineModeArg::Identity => Self::Identity,
2650 }
2651 }
2652}
2653
2654#[derive(Clone, Copy, Debug, clap::ValueEnum)]
2655enum CiProviderArg {
2656 Github,
2657 Gitlab,
2658}
2659
2660#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2662enum TypeAwareRequireArg {
2663 BestEffort,
2665 Complete,
2667}
2668
2669impl From<TypeAwareRequireArg> for fallow_config::TypeAwareRequire {
2670 fn from(value: TypeAwareRequireArg) -> Self {
2671 match value {
2672 TypeAwareRequireArg::BestEffort => Self::BestEffort,
2673 TypeAwareRequireArg::Complete => Self::Complete,
2674 }
2675 }
2676}
2677
2678#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2680pub enum EffortFilter {
2681 Low,
2682 Medium,
2683 High,
2684}
2685
2686impl EffortFilter {
2687 const fn to_estimate(self) -> fallow_output::EffortEstimate {
2689 match self {
2690 Self::Low => fallow_output::EffortEstimate::Low,
2691 Self::Medium => fallow_output::EffortEstimate::Medium,
2692 Self::High => fallow_output::EffortEstimate::High,
2693 }
2694 }
2695}
2696
2697#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2699pub enum HealthSeverityCli {
2700 Moderate,
2701 High,
2702 Critical,
2703}
2704
2705impl HealthSeverityCli {
2706 const fn to_health_severity(self) -> fallow_output::FindingSeverity {
2708 match self {
2709 Self::Moderate => fallow_output::FindingSeverity::Moderate,
2710 Self::High => fallow_output::FindingSeverity::High,
2711 Self::Critical => fallow_output::FindingSeverity::Critical,
2712 }
2713 }
2714}
2715
2716#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2722pub enum EmailModeArg {
2723 Raw,
2725 Handle,
2727 Anonymized,
2729 #[value(hide = true)]
2731 Hash,
2732}
2733
2734impl EmailModeArg {
2735 const fn to_config(self) -> fallow_config::EmailMode {
2737 match self {
2738 Self::Raw => fallow_config::EmailMode::Raw,
2739 Self::Handle => fallow_config::EmailMode::Handle,
2740 Self::Anonymized => fallow_config::EmailMode::Anonymized,
2741 Self::Hash => fallow_config::EmailMode::Hash,
2742 }
2743 }
2744}
2745
2746#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2748pub enum AuditGateArg {
2749 NewOnly,
2751 All,
2753}
2754
2755impl From<AuditGateArg> for fallow_config::AuditGate {
2756 fn from(value: AuditGateArg) -> Self {
2757 match value {
2758 AuditGateArg::NewOnly => Self::NewOnly,
2759 AuditGateArg::All => Self::All,
2760 }
2761 }
2762}
2763
2764fn parse_min_occurrences(s: &str) -> Result<usize, String> {
2768 let value: usize = s
2769 .parse()
2770 .map_err(|_| format!("`{s}` is not a non-negative integer"))?;
2771 if value < 2 {
2772 return Err(format!(
2773 "must be at least 2 (got {value}); a single occurrence isn't a duplicate"
2774 ));
2775 }
2776 Ok(value)
2777}
2778
2779fn resolve_audit_baseline_path(
2785 root: &std::path::Path,
2786 cli: Option<&std::path::Path>,
2787 config: Option<&str>,
2788) -> Option<PathBuf> {
2789 let path = cli.map(std::path::Path::to_path_buf).or_else(|| {
2790 config.map(|p| {
2791 let path = PathBuf::from(p);
2792 if path_util::is_absolute_path_any_platform(&path) {
2793 path
2794 } else {
2795 root.join(path)
2796 }
2797 })
2798 })?;
2799 if path_util::is_absolute_path_any_platform(&path) {
2800 Some(path)
2801 } else {
2802 Some(root.join(path))
2803 }
2804}
2805
2806fn emit_known_failure(
2807 message: &str,
2808 exit_code: u8,
2809 output: fallow_config::OutputFormat,
2810 reason: telemetry::FailureReason,
2811) -> ExitCode {
2812 telemetry::note_failure_reason(reason);
2813 emit_error(message, exit_code, output)
2814}
2815
2816fn emit_known_failure_with_style(
2817 message: &str,
2818 exit_code: u8,
2819 output: fallow_config::OutputFormat,
2820 json_style: json_style::JsonStyle,
2821 reason: telemetry::FailureReason,
2822) -> ExitCode {
2823 telemetry::note_failure_reason(reason);
2824 error::emit_error_with_style(message, exit_code, output, json_style)
2825}
2826
2827fn unsupported_security_global(cli: &Cli) -> Option<&'static str> {
2828 if cli.baseline.is_some() {
2829 Some("--baseline")
2830 } else if cli.save_baseline.is_some() {
2831 Some("--save-baseline")
2832 } else if cli.fail_on_stale_baseline {
2833 Some("--fail-on-stale-baseline")
2834 } else if cli.production {
2835 Some("--production")
2836 } else if cli.no_production {
2837 Some("--no-production")
2838 } else if cli.group_by.is_some() {
2839 Some("--group-by")
2840 } else if cli.performance {
2841 Some("--performance")
2842 } else if cli.explain_skipped {
2843 Some("--explain-skipped")
2844 } else if cli.fail_on_regression {
2845 Some("--fail-on-regression")
2846 } else if cli.regression_baseline.is_some() {
2847 Some("--regression-baseline")
2848 } else if cli.save_regression_baseline.is_some() {
2849 Some("--save-regression-baseline")
2850 } else if cli.dupes_mode.is_some() {
2851 Some("--dupes-mode")
2852 } else if cli.dupes_threshold.is_some() {
2853 Some("--dupes-threshold")
2854 } else if cli.dupes_min_tokens.is_some() {
2855 Some("--dupes-min-tokens")
2856 } else if cli.dupes_min_lines.is_some() {
2857 Some("--dupes-min-lines")
2858 } else if cli.dupes_min_occurrences.is_some() {
2859 Some("--dupes-min-occurrences")
2860 } else if cli.dupes_skip_local {
2861 Some("--dupes-skip-local")
2862 } else if cli.dupes_cross_language {
2863 Some("--dupes-cross-language")
2864 } else if cli.dupes_ignore_imports {
2865 Some("--dupes-ignore-imports")
2866 } else if cli.dupes_no_ignore_imports {
2867 Some("--dupes-no-ignore-imports")
2868 } else if cli.include_entry_exports {
2869 Some("--include-entry-exports")
2870 } else {
2871 None
2872 }
2873}
2874
2875struct DispatchContext<'a> {
2876 cli: &'a Cli,
2877 root: &'a std::path::Path,
2878 output: fallow_config::OutputFormat,
2879 quiet: bool,
2880 fail_on_issues: bool,
2881 json_style: json_style::JsonStyle,
2882 threads: usize,
2883 tolerance: regression::Tolerance,
2884 save_regression_file: Option<&'a std::path::PathBuf>,
2885 save_to_config: bool,
2886}
2887
2888impl DispatchContext<'_> {
2889 fn production_modes(
2890 &self,
2891 dead_code: bool,
2892 health: bool,
2893 dupes: bool,
2894 ) -> Result<ProductionModes, ExitCode> {
2895 resolve_production_modes(self.cli, self.root, self.output, dead_code, health, dupes)
2896 }
2897
2898 fn production_for(
2899 &self,
2900 analysis: fallow_config::ProductionAnalysis,
2901 ) -> Result<bool, ExitCode> {
2902 self.production_modes(false, false, false)
2903 .map(|modes| modes.for_analysis(analysis))
2904 }
2905
2906 fn regression_opts(&self, scoped: bool) -> regression::RegressionOpts<'_> {
2907 regression::RegressionOpts {
2908 fail_on_regression: self.cli.fail_on_regression,
2909 tolerance: self.tolerance,
2910 regression_baseline_file: self.cli.regression_baseline.as_deref(),
2911 save_target: if let Some(path) = self.save_regression_file {
2912 regression::SaveRegressionTarget::File(path)
2913 } else if self.save_to_config {
2914 regression::SaveRegressionTarget::Config
2915 } else {
2916 regression::SaveRegressionTarget::None
2917 },
2918 scoped,
2919 quiet: self.quiet,
2920 output: self.output,
2921 }
2922 }
2923}
2924
2925#[cfg(unix)]
2940fn signal_test_helper() -> ExitCode {
2941 use std::io::Write as _;
2942 use std::process::Command;
2943
2944 if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER_GRACEFUL").is_some() {
2945 signal::set_graceful_mode();
2946 }
2947
2948 let mut command = Command::new("sleep");
2949 command.arg("30");
2950 let child = match signal::ScopedChild::spawn(&mut command) {
2951 Ok(c) => c,
2952 Err(err) => {
2953 let _ = writeln!(std::io::stderr(), "spawn sleep failed: {err}");
2954 return ExitCode::from(2);
2955 }
2956 };
2957 let pid = child.id();
2958 let stdout = std::io::stdout();
2959 let mut lock = stdout.lock();
2960 let _ = writeln!(lock, "{pid}");
2961 let _ = lock.flush();
2962 drop(lock);
2963 let _ = child.wait_with_output();
2964 if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER_GRACEFUL").is_some() {
2965 return ExitCode::SUCCESS;
2966 }
2967 std::thread::sleep(std::time::Duration::from_secs(5));
2968 ExitCode::SUCCESS
2969}
2970
2971#[cfg(not(unix))]
2972fn signal_test_helper() -> ExitCode {
2973 ExitCode::from(2)
2974}
2975
2976fn install_spawn_hooks() {
2977 fallow_engine::churn::set_spawn_hook(signal::scoped_child::output);
2978 fallow_engine::changed_files::set_spawn_hook(signal::scoped_child::output);
2979}
2980
2981fn install_signal_handlers() {
2982 if let Err(err) = signal::install_handlers() {
2983 use std::io::Write as _;
2984 let stderr = std::io::stderr();
2985 let mut lock = stderr.lock();
2986 let _ = writeln!(lock, "fallow: failed to install signal handlers: {err}");
2987 }
2988}
2989
2990fn redirect_report_to_file(
2995 path: &std::path::Path,
2996 output: fallow_config::OutputFormat,
2997) -> Result<(), ExitCode> {
2998 if let Some(parent) = path.parent()
2999 && !parent.as_os_str().is_empty()
3000 && let Err(e) = std::fs::create_dir_all(parent)
3001 {
3002 return Err(emit_error(
3003 &format!(
3004 "failed to create {} for --output-file: {e}",
3005 parent.display()
3006 ),
3007 2,
3008 output,
3009 ));
3010 }
3011 match std::fs::File::create(path) {
3012 Ok(file) => {
3013 report::sink::set_file_sink(file);
3014 colored::control::set_override(false);
3015 Ok(())
3016 }
3017 Err(e) => Err(emit_error(
3018 &format!("failed to open {} for --output-file: {e}", path.display()),
3019 2,
3020 output,
3021 )),
3022 }
3023}
3024
3025fn finalize_report_file(
3028 path: &std::path::Path,
3029 quiet: bool,
3030 output: fallow_config::OutputFormat,
3031) -> Result<(), ExitCode> {
3032 if let Err(e) = report::sink::flush() {
3033 return Err(emit_error(
3034 &format!("failed to write {}: {e}", path.display()),
3035 2,
3036 output,
3037 ));
3038 }
3039 if !quiet && report::sink::wrote() {
3043 eprintln!("Report written to {}", path.display());
3044 }
3045 Ok(())
3046}
3047
3048pub fn run() -> ExitCode {
3053 install_signal_handlers();
3054 install_spawn_hooks();
3055
3056 if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER").is_some() {
3057 return signal_test_helper();
3058 }
3059
3060 let (mut cli, fmt) = match parse_cli_args() {
3061 Ok(parsed) => parsed,
3062 Err(code) => return code,
3063 };
3064 if cli.pretty && !fmt.payload_is_json {
3065 eprintln!(
3066 "Error: --pretty requires JSON output. Use --format json --pretty, or remove --pretty."
3067 );
3068 return ExitCode::from(2);
3069 }
3070
3071 if let Some(code) = run_schema_command_if_requested(&cli, fmt.json_style) {
3072 return code;
3073 }
3074
3075 if let Some(code) = run_telemetry_command_if_requested(&mut cli, fmt.output, fmt.json_style) {
3076 return code;
3077 }
3078 if let Some(code) = run_doctor_command_if_requested(&cli, &fmt) {
3079 return code;
3080 }
3081 if is_impact_statusline(&cli) {
3082 let (root, _) = match validate_inputs(&cli, fmt.output, fmt.json_style) {
3083 Ok(validated) => validated,
3084 Err(code) => return code,
3085 };
3086 return cli_impact::render_impact_statusline(&root);
3087 }
3088 let telemetry_run = start_telemetry_run(&cli, &fmt);
3089
3090 let (root, threads) = match validate_inputs(&cli, fmt.output, fmt.json_style) {
3091 Ok(v) => v,
3092 Err(code) => {
3093 return record_run_epilogue(telemetry_run, code, None, cli.parent_run.as_deref());
3094 }
3095 };
3096
3097 let FormatConfig {
3098 output,
3099 payload_is_json: _,
3100 quiet,
3101 fail_on_issues,
3102 json_style,
3103 } = fmt;
3104
3105 let tolerance =
3106 match run_pre_dispatch_checks(&cli, &root, output, json_style, quiet, telemetry_run) {
3107 Ok(tolerance) => tolerance,
3108 Err(code) => return code,
3109 };
3110
3111 let (save_regression_file, save_to_config) = regression_save_targets(&cli);
3112
3113 let command = cli.command.take();
3114 let dispatch = DispatchContext {
3115 cli: &cli,
3116 root: &root,
3117 output,
3118 quiet,
3119 fail_on_issues,
3120 json_style,
3121 threads,
3122 tolerance,
3123 save_regression_file: save_regression_file.as_ref(),
3124 save_to_config,
3125 };
3126 let exit_code = match dispatch_and_finalize(&dispatch, command) {
3127 Ok(code) => code,
3128 Err(code) => return code,
3129 };
3130 record_run_epilogue(telemetry_run, exit_code, None, cli.parent_run.as_deref())
3131}
3132
3133#[doc(hidden)]
3137pub fn benchmark_fix_dry_run(root: &Path, threads: usize) -> (ExitCode, usize) {
3138 let config_path = None;
3139 fix::run_fix_with_count(&fix::FixOptions {
3140 root,
3141 config_path: &config_path,
3142 output: fallow_config::OutputFormat::Json,
3143 json_style: json_style::JsonStyle::Compact,
3144 no_cache: true,
3145 threads,
3146 quiet: true,
3147 emit_output: false,
3148 allow_remote_extends: false,
3149 dry_run: true,
3150 yes: false,
3151 production: false,
3152 no_create_config: true,
3153 type_aware: None,
3154 type_aware_projects: &[],
3155 type_aware_require: None,
3156 scope: None,
3157 })
3158}
3159
3160#[doc(hidden)]
3163pub use audit::AuditReviewBenchmarkCorpus;
3164
3165#[doc(hidden)]
3168pub fn create_audit_review_benchmark_corpus(
3169 root: &Path,
3170 changed_files: &[PathBuf],
3171 threads: usize,
3172) -> Result<AuditReviewBenchmarkCorpus, ExitCode> {
3173 audit::create_audit_review_benchmark_corpus(root, changed_files, threads)
3174}
3175
3176#[doc(hidden)]
3179pub fn benchmark_audit_review_brief_many_changed_files_json(
3180 corpus: &mut AuditReviewBenchmarkCorpus,
3181) -> (ExitCode, usize, usize, usize, usize, usize) {
3182 match audit::benchmark_audit_review_brief_many_changed_files_json(corpus) {
3183 Ok(result) => (
3184 ExitCode::SUCCESS,
3185 result.introduced_count,
3186 result.inherited_count,
3187 result.public_api_added_count,
3188 result.decision_count,
3189 result.rendered_bytes,
3190 ),
3191 Err(code) => (code, 0, 0, 0, 0, 0),
3192 }
3193}
3194
3195#[doc(hidden)]
3196pub use inspect::InspectBenchmarkCorpus;
3197
3198#[doc(hidden)]
3201pub fn create_inspect_benchmark_corpus(root: &Path, threads: usize) -> InspectBenchmarkCorpus {
3202 inspect::create_inspect_benchmark_corpus(root, threads)
3203}
3204
3205#[doc(hidden)]
3208pub fn benchmark_inspect_file_evidence_bundle_json(
3209 root: &Path,
3210 threads: usize,
3211 corpus: &InspectBenchmarkCorpus,
3212) -> (ExitCode, usize, usize) {
3213 match inspect::benchmark_inspect_file_evidence_bundle_json(root, threads, corpus) {
3214 Ok((child_call_count, rendered_bytes)) => {
3215 (ExitCode::SUCCESS, child_call_count, rendered_bytes)
3216 }
3217 Err(_) => (ExitCode::from(2), 0, 0),
3218 }
3219}
3220
3221#[doc(hidden)]
3224pub fn benchmark_dead_code_json(root: &Path, threads: usize) -> (ExitCode, usize, usize) {
3225 match check::benchmark_dead_code_json(root, threads) {
3226 Ok((issue_count, rendered_bytes)) => (ExitCode::SUCCESS, issue_count, rendered_bytes),
3227 Err(code) => (code, 0, 0),
3228 }
3229}
3230
3231#[doc(hidden)]
3234pub fn benchmark_security_json(root: &Path, threads: usize) -> (ExitCode, usize, usize) {
3235 match security::benchmark_security_json(root, threads) {
3236 Ok((finding_count, rendered_bytes)) => (ExitCode::SUCCESS, finding_count, rendered_bytes),
3237 Err(code) => (code, 0, 0),
3238 }
3239}
3240
3241#[doc(hidden)]
3242pub use security::{SecurityBlindSpotsBenchmarkResult, SecuritySurvivorsBenchmarkCorpus};
3243
3244#[doc(hidden)]
3247pub fn create_security_survivors_benchmark_corpus(
3248 root: &Path,
3249 threads: usize,
3250) -> Result<SecuritySurvivorsBenchmarkCorpus, ExitCode> {
3251 security::create_security_survivors_benchmark_corpus(root, threads)
3252}
3253
3254#[doc(hidden)]
3257pub fn benchmark_security_survivors_json(
3258 corpus: &SecuritySurvivorsBenchmarkCorpus,
3259) -> (ExitCode, usize, usize, usize, usize, usize) {
3260 match security::benchmark_security_survivors_json(corpus) {
3261 Ok((survivors, dismissed, needs_human_review, unverdicted, rendered_bytes)) => (
3262 ExitCode::SUCCESS,
3263 survivors,
3264 dismissed,
3265 needs_human_review,
3266 unverdicted,
3267 rendered_bytes,
3268 ),
3269 Err(_) => (ExitCode::from(2), 0, 0, 0, 0, 0),
3270 }
3271}
3272
3273#[doc(hidden)]
3276pub fn benchmark_security_blind_spots_json(
3277 root: &Path,
3278 diagnostics: &[fallow_types::results::SecurityUnresolvedCalleeDiagnostic],
3279) -> SecurityBlindSpotsBenchmarkResult {
3280 security::benchmark_security_blind_spots_json(root, diagnostics)
3281}
3282
3283#[doc(hidden)]
3286pub fn benchmark_list_json(root: &Path, threads: usize) -> (ExitCode, usize, usize, usize, usize) {
3287 match list::benchmark_list_json(root, threads) {
3288 Ok((file_count, entry_point_count, workspace_count, rendered_bytes)) => (
3289 ExitCode::SUCCESS,
3290 file_count,
3291 entry_point_count,
3292 workspace_count,
3293 rendered_bytes,
3294 ),
3295 Err(code) => (code, 0, 0, 0, 0),
3296 }
3297}
3298
3299#[doc(hidden)]
3302pub fn benchmark_list_boundaries_json(
3303 root: &Path,
3304 threads: usize,
3305) -> (ExitCode, usize, usize, usize, usize) {
3306 match list::benchmark_list_boundaries_json(root, threads) {
3307 Ok((zone_count, rule_count, matched_file_count, rendered_bytes)) => (
3308 ExitCode::SUCCESS,
3309 zone_count,
3310 rule_count,
3311 matched_file_count,
3312 rendered_bytes,
3313 ),
3314 Err(code) => (code, 0, 0, 0, 0),
3315 }
3316}
3317
3318#[doc(hidden)]
3321pub use watch::WatchFilterBenchmarkGlobalGitignore;
3322
3323#[doc(hidden)]
3326pub fn create_watch_filter_benchmark_global_gitignore() -> WatchFilterBenchmarkGlobalGitignore {
3327 watch::create_benchmark_global_gitignore()
3328}
3329
3330#[doc(hidden)]
3333pub fn benchmark_watch_filter_initialization(
3334 config: &fallow_config::ResolvedConfig,
3335 global_gitignore: &WatchFilterBenchmarkGlobalGitignore,
3336) -> (usize, usize) {
3337 watch::benchmark_filter_initialization(config, global_gitignore)
3338}
3339
3340#[doc(hidden)]
3343pub fn benchmark_viz_html(root: &Path, threads: usize) -> (ExitCode, usize, usize, usize) {
3344 match viz::benchmark_viz_html(root, threads) {
3345 Ok((file_count, edge_count, rendered_bytes)) => {
3346 (ExitCode::SUCCESS, file_count, edge_count, rendered_bytes)
3347 }
3348 Err(code) => (code, 0, 0, 0),
3349 }
3350}
3351
3352#[doc(hidden)]
3355pub fn benchmark_rule_pack_test_json(root: &Path, threads: usize) -> (ExitCode, usize, usize) {
3356 match rule_pack::benchmark_rule_pack_test_json(root, threads) {
3357 Ok((finding_count, rendered_bytes)) => (ExitCode::SUCCESS, finding_count, rendered_bytes),
3358 Err(code) => (code, 0, 0),
3359 }
3360}
3361
3362#[doc(hidden)]
3365pub fn benchmark_recommend_json(root: &Path) -> (ExitCode, usize, usize, bool, usize) {
3366 match onboarding::benchmark_recommend_json(root) {
3367 Ok((decision_count, framework_count, heterogeneous, rendered_bytes)) => (
3368 ExitCode::SUCCESS,
3369 decision_count,
3370 framework_count,
3371 heterogeneous,
3372 rendered_bytes,
3373 ),
3374 Err(_) => (ExitCode::from(2), 0, 0, false, 0),
3375 }
3376}
3377
3378#[doc(hidden)]
3381pub fn benchmark_runtime_coverage_analyze_json(
3382 root: &Path,
3383 runtime_coverage_path: &Path,
3384 response_bytes: &[u8],
3385 threads: usize,
3386) -> (ExitCode, usize, usize, usize, String) {
3387 match coverage::benchmark_local_json(root, runtime_coverage_path, response_bytes, threads) {
3388 Ok((finding_count, hot_path_count, request_bytes, rendered)) => (
3389 ExitCode::SUCCESS,
3390 finding_count,
3391 hot_path_count,
3392 request_bytes,
3393 rendered,
3394 ),
3395 Err(code) => (code, 0, 0, 0, String::new()),
3396 }
3397}
3398
3399fn is_impact_statusline(cli: &Cli) -> bool {
3402 matches!(
3403 cli.command.as_ref(),
3404 Some(Command::Impact {
3405 subcommand: Some(ImpactCli::Statusline),
3406 all: false,
3407 ..
3408 })
3409 )
3410}
3411
3412fn dispatch_and_finalize(
3416 dispatch: &DispatchContext<'_>,
3417 command: Option<Command>,
3418) -> Result<ExitCode, ExitCode> {
3419 let cli = dispatch.cli;
3420 let output = dispatch.output;
3421 let quiet = dispatch.quiet;
3422
3423 if let Some(path) = cli.output_file.as_deref()
3426 && let Err(code) = redirect_report_to_file(path, output)
3427 {
3428 return Err(code);
3429 }
3430
3431 let exit_code = if command.is_some() && cli_has_bare_coverage_input(cli) {
3432 emit_error(bare_coverage_subcommand_error_message(), 2, output)
3433 } else {
3434 match command {
3435 None => dispatch_bare_command(dispatch),
3436 Some(cmd) => dispatch_subcommand(cmd, dispatch),
3437 }
3438 };
3439
3440 if let Some(path) = cli.output_file.as_deref()
3441 && let Err(code) = finalize_report_file(path, quiet, output)
3442 {
3443 return Err(code);
3444 }
3445 Ok(exit_code)
3446}
3447
3448fn run_telemetry_command_if_requested(
3449 cli: &mut Cli,
3450 output: fallow_config::OutputFormat,
3451 json_style: json_style::JsonStyle,
3452) -> Option<ExitCode> {
3453 if matches!(cli.command, Some(Command::Telemetry { .. }))
3454 && let Some(Command::Telemetry { subcommand }) = cli.command.take()
3455 {
3456 return Some(telemetry::run(
3457 map_telemetry_subcommand(subcommand),
3458 output,
3459 json_style,
3460 ));
3461 }
3462 None
3463}
3464
3465fn run_doctor_command_if_requested(cli: &Cli, format: &FormatConfig) -> Option<ExitCode> {
3470 if !matches!(cli.command, Some(Command::Doctor)) {
3471 return None;
3472 }
3473
3474 if let Some(flag) = unsupported_doctor_option(cli) {
3475 let message = if flag == "--output-file" {
3476 "--output-file is not valid with `fallow doctor`; doctor is read-only and writes its report to stdout".to_string()
3477 } else {
3478 format!("{flag} is not valid with `fallow doctor`.")
3479 };
3480 return Some(crate::error::emit_error_with_style(
3481 &message,
3482 2,
3483 format.output,
3484 format.json_style,
3485 ));
3486 }
3487 if let Err(code) = doctor::validate_output(format.output, format.json_style) {
3488 return Some(code);
3489 }
3490
3491 let root = cli.root.clone().unwrap_or_else(|| {
3492 std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
3493 });
3494 let config_path = cli.config.as_ref().map(|path| {
3495 if path_util::is_absolute_path_any_platform(path) {
3496 path.clone()
3497 } else {
3498 root.join(path)
3499 }
3500 });
3501 let report = doctor::collect_report(&root, config_path.as_deref());
3502 Some(doctor::render_report(
3503 &report,
3504 format.output,
3505 format.json_style,
3506 ))
3507}
3508
3509fn unsupported_doctor_option(cli: &Cli) -> Option<&'static str> {
3513 [
3514 (cli.allow_remote_extends, "--allow-remote-extends"),
3515 (cli.no_cache, "--no-cache"),
3516 (cli.threads.is_some(), "--threads"),
3517 (cli.changed_since.is_some(), "--changed-since"),
3518 (cli.diff_file.is_some(), "--diff-file"),
3519 (cli.diff_stdin, "--diff-stdin"),
3520 (cli.churn_file.is_some(), "--churn-file"),
3521 (cli.max_file_size.is_some(), "--max-file-size"),
3522 (cli.baseline.is_some(), "--baseline"),
3523 (cli.baseline_mode.is_some(), "--baseline-mode"),
3524 (cli.parent_run.is_some(), "--parent-run"),
3525 (cli.save_baseline.is_some(), "--save-baseline"),
3526 (cli.production, "--production"),
3527 (cli.no_production, "--no-production"),
3528 (cli.production_dead_code, "--production-dead-code"),
3529 (cli.production_health, "--production-health"),
3530 (cli.production_dupes, "--production-dupes"),
3531 (cli.workspace.is_some(), "--workspace"),
3532 (cli.changed_workspaces.is_some(), "--changed-workspaces"),
3533 (cli.group_by.is_some(), "--group-by"),
3534 (cli.performance, "--performance"),
3535 (cli.explain, "--explain"),
3536 (cli.explain_skipped, "--explain-skipped"),
3537 (cli.summary, "--summary"),
3538 (cli.ci, "--ci"),
3539 (cli.fail_on_issues, "--fail-on-issues"),
3540 (cli.sarif_file.is_some(), "--sarif-file"),
3541 (cli.output_file.is_some(), "--output-file"),
3542 (cli.report_path_prefix.is_some(), "--report-path-prefix"),
3543 (cli.fail_on_regression, "--fail-on-regression"),
3544 (cli.fail_on_stale_baseline, "--fail-on-stale-baseline"),
3545 (cli.tolerance != "0", "--tolerance"),
3546 (cli.regression_baseline.is_some(), "--regression-baseline"),
3547 (
3548 cli.save_regression_baseline.is_some(),
3549 "--save-regression-baseline",
3550 ),
3551 (!cli.only.is_empty(), "--only"),
3552 (!cli.skip.is_empty(), "--skip"),
3553 (cli.dupes_mode.is_some(), "--dupes-mode"),
3554 (cli.dupes_near, "--dupes-near"),
3555 (cli.dupes_threshold.is_some(), "--dupes-threshold"),
3556 (cli.dupes_min_tokens.is_some(), "--dupes-min-tokens"),
3557 (cli.dupes_min_lines.is_some(), "--dupes-min-lines"),
3558 (
3559 cli.dupes_min_occurrences.is_some(),
3560 "--dupes-min-occurrences",
3561 ),
3562 (cli.dupes_skip_local, "--dupes-skip-local"),
3563 (cli.dupes_cross_language, "--dupes-cross-language"),
3564 (cli.dupes_ignore_imports, "--dupes-ignore-imports"),
3565 (cli.dupes_no_ignore_imports, "--dupes-no-ignore-imports"),
3566 (cli.score, "--score"),
3567 (cli.trend, "--trend"),
3568 (cli.save_snapshot.is_some(), "--save-snapshot"),
3569 (cli.coverage.is_some(), "--coverage"),
3570 (cli.coverage_root.is_some(), "--coverage-root"),
3571 (cli.include_entry_exports, "--include-entry-exports"),
3572 (cli.type_aware, "--type-aware"),
3573 (cli.no_type_aware, "--no-type-aware"),
3574 (!cli.type_aware_project.is_empty(), "--type-aware-project"),
3575 (cli.type_aware_require.is_some(), "--type-aware-require"),
3576 ]
3577 .into_iter()
3578 .find_map(|(used, flag)| used.then_some(flag))
3579}
3580
3581fn run_schema_command_if_requested(
3582 cli: &Cli,
3583 json_style: json_style::JsonStyle,
3584) -> Option<ExitCode> {
3585 match cli.command {
3586 Some(Command::Schema) => Some(schema::run_schema(json_style)),
3587 Some(Command::ConfigSchema) => Some(init::run_config_schema(json_style)),
3588 Some(Command::PluginSchema) => Some(init::run_plugin_schema(json_style)),
3589 Some(Command::RulePackSchema) => Some(init::run_rule_pack_schema(json_style)),
3590 _ => None,
3591 }
3592}
3593
3594fn regression_save_targets(cli: &Cli) -> (Option<std::path::PathBuf>, bool) {
3595 let save_file = cli.save_regression_baseline.as_ref().and_then(|opt| {
3596 opt.as_ref()
3597 .filter(|path| !path.is_empty())
3598 .map(std::path::PathBuf::from)
3599 });
3600 let save_to_config = cli.save_regression_baseline.is_some() && save_file.is_none();
3601 (save_file, save_to_config)
3602}
3603
3604fn dispatch_bare_command(dispatch: &DispatchContext<'_>) -> ExitCode {
3605 let cli = dispatch.cli;
3606 let (run_check, run_dupes, run_health) = combined::resolve_analyses(&cli.only, &cli.skip);
3607 let production = match dispatch.production_modes(
3608 cli.production_dead_code,
3609 cli.production_health,
3610 cli.production_dupes,
3611 ) {
3612 Ok(production) => production,
3613 Err(code) => return code,
3614 };
3615 let coverage_inputs = if run_health {
3620 match resolve_health_coverage_inputs(
3621 dispatch,
3622 cli.coverage.as_deref(),
3623 cli.coverage_root.as_deref(),
3624 ) {
3625 Ok(inputs) => inputs,
3626 Err(code) => return code,
3627 }
3628 } else {
3629 ResolvedHealthCoverageInputs::default()
3630 };
3631 run_bare_combined(
3632 dispatch,
3633 production,
3634 &coverage_inputs,
3635 BareAnalyses {
3636 run_check,
3637 run_dupes,
3638 run_health,
3639 },
3640 )
3641}
3642
3643#[derive(Clone, Copy)]
3645struct BareAnalyses {
3646 run_check: bool,
3647 run_dupes: bool,
3648 run_health: bool,
3649}
3650
3651fn run_bare_combined(
3654 dispatch: &DispatchContext<'_>,
3655 production: ProductionModes,
3656 coverage_inputs: &ResolvedHealthCoverageInputs,
3657 analyses: BareAnalyses,
3658) -> ExitCode {
3659 let cli = dispatch.cli;
3660 let (output, quiet, fail_on_issues) =
3661 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
3662 let scope = match crate::scope_path::resolve_command_scope(
3663 dispatch.root,
3664 dispatch.output,
3665 cli.path.clone(),
3666 ) {
3667 Ok(scope) => scope.map(|resolved| resolved.absolute),
3668 Err(code) => return code,
3669 };
3670 let scoped_run = scope.is_some();
3671 combined::run_combined(&combined::CombinedOptions {
3672 root: dispatch.root,
3673 config_path: &cli.config,
3674 output,
3675 json_style: dispatch.json_style,
3676 no_cache: cli.no_cache,
3677 threads: dispatch.threads,
3678 quiet,
3679 allow_remote_extends: cli.allow_remote_extends,
3680 fail_on_issues,
3681 sarif_file: cli.sarif_file.as_deref(),
3682 changed_since: cli.changed_since.as_deref(),
3683 churn_file: cli.churn_file.as_deref(),
3684 baseline: cli.baseline.as_deref(),
3685 save_baseline: cli.save_baseline.as_deref(),
3686 fail_on_stale_baseline: cli.fail_on_stale_baseline,
3687 production: cli.production,
3688 production_dead_code: Some(production.dead_code),
3689 production_health: Some(production.health),
3690 production_dupes: Some(production.dupes),
3691 workspace: cli.workspace.as_deref(),
3692 changed_workspaces: cli.changed_workspaces.as_deref(),
3693 group_by: cli.group_by,
3694 type_aware: cli.type_aware_override(),
3695 type_aware_projects: &cli.type_aware_project,
3696 type_aware_require: cli.type_aware_require.map(Into::into),
3697 explain: cli.explain,
3698 explain_skipped: cli.explain_skipped,
3699 performance: cli.performance,
3700 summary: cli.summary,
3701 run_check: analyses.run_check,
3702 run_dupes: analyses.run_dupes,
3703 run_health: analyses.run_health,
3704 dupes_mode: cli.dupes_mode,
3705 dupes_near: cli.dupes_near,
3706 dupes_threshold: cli.dupes_threshold,
3707 dupes_min_tokens: cli.dupes_min_tokens,
3708 dupes_min_lines: cli.dupes_min_lines,
3709 dupes_min_occurrences: cli.dupes_min_occurrences,
3710 dupes_skip_local: cli.dupes_skip_local,
3711 dupes_cross_language: cli.dupes_cross_language,
3712 dupes_ignore_imports: resolve_ignore_imports(
3713 cli.dupes_ignore_imports,
3714 cli.dupes_no_ignore_imports,
3715 ),
3716 score: cli.score || cli.trend,
3717 trend: cli.trend,
3718 save_snapshot: cli.save_snapshot.as_ref(),
3719 coverage: coverage_inputs.coverage.as_deref(),
3720 coverage_root: coverage_inputs.coverage_root.as_deref(),
3721 include_entry_exports: cli.include_entry_exports,
3722 scope,
3723 regression_opts: dispatch.regression_opts(
3724 cli.changed_since.is_some()
3725 || cli.workspace.is_some()
3726 || cli.changed_workspaces.is_some()
3727 || scoped_run,
3728 ),
3729 })
3730}
3731
3732#[allow(
3733 clippy::too_many_lines,
3734 reason = "the command router is intentionally an exhaustive top-level dispatch table"
3735)]
3736fn dispatch_subcommand(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3737 let cli = dispatch.cli;
3738 let root = dispatch.root;
3739 let output = dispatch.output;
3740 let quiet = dispatch.quiet;
3741 match command {
3742 check @ Command::Check { .. } => dispatch_check_command(check, dispatch),
3743 Command::Watch { no_clear } => dispatch_watch(dispatch, no_clear),
3744 Command::TypeAware { subcommand } => dispatch_type_aware_command(dispatch, subcommand),
3745 Command::Doctor => unreachable!("doctor bypasses the normal dispatch epilogue"),
3746 Command::SimilarCode {
3747 subcommand,
3748 threshold,
3749 min_lines,
3750 top,
3751 file,
3752 path,
3753 } => {
3754 let scope = match crate::scope_path::resolve_command_scope(
3755 dispatch.root,
3756 dispatch.output,
3757 path,
3758 ) {
3759 Ok(scope) => scope,
3760 Err(code) => return code,
3761 };
3762 similar_code_cli::run(similar_code_cli::SimilarCodeCliInput {
3763 root,
3764 config_path: cli.config.as_deref(),
3765 allow_remote_extends: cli.allow_remote_extends,
3766 no_cache: cli.no_cache,
3767 threads: dispatch.threads,
3768 changed_since: cli.changed_since.as_deref(),
3769 diff_file: cli.diff_file.as_deref(),
3770 workspace: cli.workspace.as_deref(),
3771 changed_workspaces: cli.changed_workspaces.as_deref(),
3772 explain: cli.explain,
3773 quiet,
3774 output,
3775 json_style: dispatch.json_style,
3776 threshold,
3777 min_lines,
3778 top,
3779 files: file,
3780 scope,
3781 subcommand,
3782 })
3783 }
3784 Command::Inspect {
3785 file,
3786 symbol,
3787 symbol_chain,
3788 churn,
3789 } => dispatch_inspect_command(dispatch, file, symbol, symbol_chain, churn),
3790 Command::Trace {
3791 symbol,
3792 path,
3793 callers,
3794 callees,
3795 depth,
3796 } => dispatch_trace_command(dispatch, symbol, &path, callers, callees, depth),
3797 Command::TraceError { trace_file } => {
3798 trace_error::run_trace_error(&trace_error::TraceErrorOptions {
3799 root: dispatch.root,
3800 config_path: &dispatch.cli.config,
3801 output: dispatch.output,
3802 json_style: dispatch.json_style,
3803 no_cache: dispatch.cli.no_cache,
3804 threads: dispatch.threads,
3805 quiet: dispatch.quiet,
3806 allow_remote_extends: dispatch.cli.allow_remote_extends,
3807 trace_file: trace_file.as_deref(),
3808 })
3809 }
3810 fix @ Command::Fix { .. } => dispatch_fix_command(&fix, dispatch),
3811 init @ Command::Init { .. } => dispatch_init_command(init, root, quiet),
3812 Command::Hooks { subcommand } => {
3813 run_hooks_command(root, subcommand, output, dispatch.json_style)
3814 }
3815 Command::Agent { subcommand } => dispatch_agent_command(dispatch, subcommand),
3816 Command::Ci { subcommand } => {
3817 ci::run(map_ci_subcommand(subcommand), output, dispatch.json_style)
3818 }
3819 Command::ConfigSchema => init::run_config_schema(dispatch.json_style),
3820 Command::PluginSchema => init::run_plugin_schema(dispatch.json_style),
3821 Command::PluginCheck => plugin_check::run_plugin_check(root, output, dispatch.json_style),
3822 Command::RulePackSchema => init::run_rule_pack_schema(dispatch.json_style),
3823 Command::RulePack { subcommand } => dispatch_rule_pack_command(dispatch, subcommand),
3824 Command::Guard { files } => dispatch_guard_command(dispatch, &files),
3825 Command::CiTemplate { subcommand } => dispatch_ci_template_command(subcommand),
3826 Command::Config { path } => config::run_config_with_options(config::RunConfigInput {
3827 root,
3828 explicit_config: cli.config.as_deref(),
3829 path_only: path,
3830 output,
3831 quiet,
3832 json_style: dispatch.json_style,
3833 load_options: fallow_config::ConfigLoadOptions {
3834 allow_remote_extends: cli.allow_remote_extends,
3835 },
3836 }),
3837 Command::Recommend => onboarding::run_recommend(root, output, dispatch.json_style),
3838 list @ (Command::Workspaces | Command::List { .. }) => {
3839 dispatch_list_command(&list, dispatch)
3840 }
3841 dupes @ Command::Dupes { .. } => dispatch_dupes_command(dupes, dispatch),
3842 health @ Command::Health { .. } => dispatch_health_command(health, dispatch),
3843 Command::Flags { top } => dispatch_flags_command(dispatch, top),
3844 Command::Suppressions { file } => dispatch_suppressions_command(dispatch, &file),
3845 Command::Explain { issue_type } => {
3846 explain::run_explain(&issue_type.join(" "), output, dispatch.json_style)
3847 }
3848 audit @ Command::Audit { .. } => dispatch_audit_command(audit, dispatch),
3849 Command::AuditCache { subcommand } => dispatch_audit_cache_command(dispatch, &subcommand),
3850 Command::DecisionSurface { max_decisions } => {
3851 dispatch_decision_surface(dispatch, max_decisions)
3852 }
3853 Command::Impact {
3854 subcommand,
3855 all,
3856 sort,
3857 limit,
3858 } => dispatch_impact(
3859 root,
3860 quiet,
3861 output,
3862 dispatch.json_style,
3863 subcommand,
3864 ImpactCrossRepoOpts { all, sort, limit },
3865 ),
3866 security @ Command::Security { .. } => dispatch_security_command(security, dispatch),
3867 Command::Viz {
3868 output: viz_output,
3869 no_open,
3870 viz_format,
3871 } => dispatch_viz(dispatch, viz_output.as_deref(), no_open, viz_format),
3872 Command::Report { from } => {
3873 cli_report::run_report(&from, output, root, cli.config.as_deref())
3874 }
3875 Command::Schema => unreachable!("handled above"),
3876 migrate @ Command::Migrate { .. } => dispatch_migrate_command(migrate, root),
3877 Command::License { subcommand } => {
3878 dispatch_license_command(subcommand, output, dispatch.json_style)
3879 }
3880 Command::Telemetry { .. } => unreachable!("handled before root validation"),
3881 Command::Coverage { subcommand } => dispatch_coverage_command(dispatch, &subcommand),
3882 setup_hooks @ Command::SetupHooks { .. } => {
3883 dispatch_setup_hooks_command(&setup_hooks, dispatch)
3884 }
3885 }
3886}
3887
3888fn dispatch_type_aware_command(
3889 dispatch: &DispatchContext<'_>,
3890 subcommand: TypeAwareCli,
3891) -> ExitCode {
3892 match subcommand {
3893 TypeAwareCli::Status => {
3894 let status = fallow_api::type_aware_status(dispatch.root);
3895 match dispatch.output {
3896 fallow_config::OutputFormat::Json => {
3897 let output = type_aware_status_output(dispatch.root, status);
3898 match fallow_output::serialize_type_aware_status_json_output(
3899 output,
3900 crate::output_runtime::current_root_envelope_mode(),
3901 ) {
3902 Ok(value) => match dispatch.json_style.serialize(&value) {
3903 Ok(json) => {
3904 crate::report::sink::outln!("{json}");
3905 ExitCode::SUCCESS
3906 }
3907 Err(error) => emit_error(
3908 &format!("failed to serialize type-aware status: {error}"),
3909 2,
3910 dispatch.output,
3911 ),
3912 },
3913 Err(error) => emit_error(
3914 &format!("failed to build type-aware status: {error}"),
3915 2,
3916 dispatch.output,
3917 ),
3918 }
3919 }
3920 fallow_config::OutputFormat::Human => {
3921 if status.available {
3922 crate::report::sink::outln!(
3923 "{}",
3924 report::human_status_line(
3925 report::HumanStatus::Ok,
3926 format_args!(
3927 "Type-aware companion: available ({}, protocol {}, TypeScript {})",
3928 status.package_version.as_deref().unwrap_or("unknown"),
3929 status.protocol_version,
3930 status.backend_version.as_deref().unwrap_or("unknown"),
3931 )
3932 )
3933 );
3934 } else {
3935 crate::report::sink::outln!(
3936 "{}",
3937 report::human_status_line(
3938 report::HumanStatus::Inactive,
3939 "Type-aware companion: unavailable"
3940 )
3941 );
3942 if let Some(remediation) = status.remediation {
3943 crate::report::sink::outln!(
3944 "{}",
3945 report::human_status_line(
3946 report::HumanStatus::Warning,
3947 format_args!("Action: {remediation}")
3948 )
3949 );
3950 }
3951 }
3952 ExitCode::SUCCESS
3953 }
3954 _ => emit_error(
3955 "type-aware status supports human and json output",
3956 2,
3957 dispatch.output,
3958 ),
3959 }
3960 }
3961 }
3962}
3963
3964fn type_aware_status_output(
3965 root: &Path,
3966 status: fallow_api::TypeAwareStatus,
3967) -> fallow_output::TypeAwareStatusOutput {
3968 let companion_path = status.companion_path.as_deref().map(|path| {
3969 if let Ok(relative) = path.strip_prefix(root)
3970 && !relative.as_os_str().is_empty()
3971 {
3972 relative.to_string_lossy().replace('\\', "/")
3973 } else {
3974 path.file_name()
3975 .unwrap_or(path.as_os_str())
3976 .to_string_lossy()
3977 .into_owned()
3978 }
3979 });
3980 let remediation = status.remediation.map(|message| {
3981 let without_root = message.replace(root.to_string_lossy().as_ref(), ".");
3982 status.companion_path.as_deref().map_or_else(
3983 || without_root.clone(),
3984 |path| {
3985 without_root.replace(
3986 path.to_string_lossy().as_ref(),
3987 companion_path.as_deref().unwrap_or("fallow-type-aware"),
3988 )
3989 },
3990 )
3991 });
3992 fallow_output::TypeAwareStatusOutput {
3993 schema_version: fallow_types::envelope::SchemaVersion(
3994 fallow_output::TYPE_AWARE_STATUS_SCHEMA_VERSION,
3995 ),
3996 version: fallow_types::envelope::ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
3997 available: status.available,
3998 discovery_source: status.discovery_source.map(str::to_string),
3999 companion_path,
4000 package_version: status.package_version,
4001 protocol_version: status.protocol_version,
4002 backend_family: status.backend_family,
4003 backend_version: status.backend_version,
4004 remediation,
4005 }
4006}
4007
4008fn dispatch_check_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4010 let filters = check_issue_filters(&command);
4011 let Command::Check {
4012 include_dupes,
4013 trace,
4014 trace_file,
4015 trace_dependency,
4016 impact_closure,
4017 symbol_impact,
4018 top,
4019 file,
4020 path,
4021 ..
4022 } = command
4023 else {
4024 unreachable!("check dispatcher only handles check commands");
4025 };
4026
4027 let scope = match crate::scope_path::resolve_command_scope(dispatch.root, dispatch.output, path)
4028 {
4029 Ok(scope) => scope.map(|resolved| resolved.absolute),
4030 Err(code) => return code,
4031 };
4032
4033 dispatch_check(
4034 dispatch,
4035 &CheckDispatchArgs {
4036 filters,
4037 trace_opts: TraceOptions {
4038 trace_export: trace,
4039 trace_file,
4040 trace_dependency,
4041 impact_closure,
4042 symbol_impact,
4043 performance: dispatch.cli.performance,
4044 },
4045 include_dupes,
4046 type_aware: dispatch.cli.type_aware_override(),
4047 type_aware_project: dispatch.cli.type_aware_project.clone(),
4048 type_aware_require: dispatch.cli.type_aware_require,
4049 top,
4050 file,
4051 scope,
4052 },
4053 )
4054}
4055
4056fn check_issue_filters(command: &Command) -> IssueFilters {
4061 check_issue_filters_framework(command, &check_issue_filters_core(command))
4062}
4063
4064fn check_issue_filters_core(command: &Command) -> IssueFilters {
4067 let Command::Check {
4068 unused_files,
4069 unused_exports,
4070 unused_deps,
4071 unused_types,
4072 private_type_leaks,
4073 unused_enum_members,
4074 unused_class_members,
4075 unresolved_imports,
4076 unlisted_deps,
4077 duplicate_exports,
4078 circular_deps,
4079 re_export_cycles,
4080 boundary_violations,
4081 policy_violations,
4082 stale_suppressions,
4083 ..
4084 } = command
4085 else {
4086 unreachable!("check filter builder only handles check commands");
4087 };
4088
4089 let mut filters = IssueFilters::default();
4090 for (flag, active) in [
4091 ("--unused-files", *unused_files),
4092 ("--unused-exports", *unused_exports),
4093 ("--unused-deps", *unused_deps),
4094 ("--unused-types", *unused_types),
4095 ("--private-type-leaks", *private_type_leaks),
4096 ("--unused-enum-members", *unused_enum_members),
4097 ("--unused-class-members", *unused_class_members),
4098 ("--unresolved-imports", *unresolved_imports),
4099 ("--unlisted-deps", *unlisted_deps),
4100 ("--duplicate-exports", *duplicate_exports),
4101 ("--circular-deps", *circular_deps),
4102 ("--re-export-cycles", *re_export_cycles),
4103 ("--boundary-violations", *boundary_violations),
4104 ("--policy-violations", *policy_violations),
4105 ("--stale-suppressions", *stale_suppressions),
4106 ] {
4107 enable_check_filter(&mut filters, flag, active);
4108 }
4109 filters
4110}
4111
4112fn check_issue_filters_framework(command: &Command, base: &IssueFilters) -> IssueFilters {
4115 let Command::Check {
4116 unused_store_members,
4117 unprovided_injects,
4118 unrendered_components,
4119 unused_component_props,
4120 unused_component_emits,
4121 unused_component_inputs,
4122 unused_component_outputs,
4123 unused_svelte_events,
4124 unused_server_actions,
4125 unused_load_data_keys,
4126 unused_catalog_entries,
4127 empty_catalog_groups,
4128 unresolved_catalog_references,
4129 unused_dependency_overrides,
4130 misconfigured_dependency_overrides,
4131 ..
4132 } = command
4133 else {
4134 unreachable!("check filter builder only handles check commands");
4135 };
4136
4137 let mut filters = base.clone();
4138 for (flag, active) in [
4139 ("--unused-store-members", *unused_store_members),
4140 ("--unprovided-injects", *unprovided_injects),
4141 ("--unrendered-components", *unrendered_components),
4142 ("--unused-component-props", *unused_component_props),
4143 ("--unused-component-emits", *unused_component_emits),
4144 ("--unused-component-inputs", *unused_component_inputs),
4145 ("--unused-component-outputs", *unused_component_outputs),
4146 ("--unused-svelte-events", *unused_svelte_events),
4147 ("--unused-server-actions", *unused_server_actions),
4148 ("--unused-load-data-keys", *unused_load_data_keys),
4149 ("--unused-catalog-entries", *unused_catalog_entries),
4150 ("--empty-catalog-groups", *empty_catalog_groups),
4151 (
4152 "--unresolved-catalog-references",
4153 *unresolved_catalog_references,
4154 ),
4155 (
4156 "--unused-dependency-overrides",
4157 *unused_dependency_overrides,
4158 ),
4159 (
4160 "--misconfigured-dependency-overrides",
4161 *misconfigured_dependency_overrides,
4162 ),
4163 ] {
4164 enable_check_filter(&mut filters, flag, active);
4165 }
4166 filters
4167}
4168
4169fn enable_check_filter(filters: &mut IssueFilters, flag: &str, active: bool) {
4170 if active {
4171 assert!(
4172 filters.enable_cli_filter_flag(flag),
4173 "check command uses unregistered dead-code filter flag {flag}"
4174 );
4175 }
4176}
4177
4178fn dispatch_inspect_command(
4179 dispatch: &DispatchContext<'_>,
4180 file: Option<String>,
4181 symbol: Option<String>,
4182 symbol_chain: bool,
4183 churn: bool,
4184) -> ExitCode {
4185 let target = match (file, symbol) {
4186 (Some(file), None) => inspect::InspectTarget::File { file },
4187 (None, Some(symbol)) => match selector::parse_file_symbol_selector(&symbol) {
4188 Some((file, export_name)) => inspect::InspectTarget::Symbol {
4189 file: file.to_string(),
4190 export_name: export_name.to_string(),
4191 },
4192 None => {
4193 return emit_error(
4194 "--symbol must be formatted as FILE:EXPORT",
4195 2,
4196 dispatch.output,
4197 );
4198 }
4199 },
4200 _ => {
4201 return emit_error(
4202 "inspect requires exactly one of --file or --symbol",
4203 2,
4204 dispatch.output,
4205 );
4206 }
4207 };
4208
4209 let churn_config = if churn {
4210 match load_config_for_analysis(
4211 dispatch.root,
4212 &dispatch.cli.config,
4213 ConfigLoadOptions {
4214 output: dispatch.output,
4215 no_cache: dispatch.cli.no_cache,
4216 threads: dispatch.threads,
4217 production_override: None,
4218 quiet: dispatch.quiet,
4219 allow_remote_extends: dispatch.cli.allow_remote_extends,
4220 },
4221 fallow_config::ProductionAnalysis::Health,
4222 ) {
4223 Ok(config) => Some(config),
4224 Err(code) => return code,
4225 }
4226 } else {
4227 None
4228 };
4229
4230 inspect::run_inspect(&inspect::InspectOptions {
4231 root: dispatch.root,
4232 config_path: dispatch.cli.config.as_ref(),
4233 output: dispatch.output,
4234 json_style: dispatch.json_style,
4235 no_cache: dispatch.cli.no_cache,
4236 no_production: dispatch.cli.no_production,
4237 max_file_size: dispatch.cli.max_file_size,
4238 threads: dispatch.threads,
4239 quiet: dispatch.quiet,
4240 production: dispatch.cli.production,
4241 workspace: dispatch.cli.workspace.as_ref(),
4242 target,
4243 churn_cache_dir: churn_config
4244 .as_ref()
4245 .map(|config| config.cache_dir.as_path()),
4246 symbol_chain,
4247 type_aware: dispatch.cli.type_aware_override(),
4248 type_aware_projects: &dispatch.cli.type_aware_project,
4249 type_aware_require: dispatch.cli.type_aware_require.map(Into::into),
4250 })
4251}
4252
4253fn dispatch_trace_command(
4254 dispatch: &DispatchContext<'_>,
4255 symbol: Option<String>,
4256 path: &[String],
4257 callers: bool,
4258 callees: bool,
4259 depth: Option<u32>,
4260) -> ExitCode {
4261 if let [from, to] = path {
4262 return trace_path::run_trace_path(&trace_path::TracePathOptions {
4263 root: dispatch.root,
4264 config_path: &dispatch.cli.config,
4265 output: dispatch.output,
4266 json_style: dispatch.json_style,
4267 no_cache: dispatch.cli.no_cache,
4268 threads: dispatch.threads,
4269 quiet: dispatch.quiet,
4270 allow_remote_extends: dispatch.cli.allow_remote_extends,
4271 from,
4272 to,
4273 });
4274 }
4275 let Some(symbol) = symbol else {
4276 return emit_error(
4277 "trace requires a FILE:SYMBOL target or --path <FROM> <TO>",
4278 2,
4279 dispatch.output,
4280 );
4281 };
4282 trace_chain::run_trace(&trace_chain::TraceChainOptions {
4283 root: dispatch.root,
4284 config_path: &dispatch.cli.config,
4285 output: dispatch.output,
4286 json_style: dispatch.json_style,
4287 no_cache: dispatch.cli.no_cache,
4288 threads: dispatch.threads,
4289 quiet: dispatch.quiet,
4290 allow_remote_extends: dispatch.cli.allow_remote_extends,
4291 target: symbol,
4292 callers,
4293 callees,
4294 depth: depth.unwrap_or(fallow_types::trace_chain::DEFAULT_TRACE_DEPTH),
4295 })
4296}
4297
4298fn dispatch_security_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4299 let Command::Security {
4300 subcommand,
4301 runtime_coverage,
4302 min_invocations_hot,
4303 file,
4304 gate,
4305 surface,
4306 path,
4307 } = command
4308 else {
4309 unreachable!("security dispatcher only handles security commands");
4310 };
4311
4312 let scope = match crate::scope_path::resolve_command_scope(dispatch.root, dispatch.output, path)
4313 {
4314 Ok(scope) => scope.map(|resolved| resolved.absolute),
4315 Err(code) => return code,
4316 };
4317
4318 let gate = gate.map(security::SecurityGateArg::into_mode);
4319 let cli = dispatch.cli;
4320 let (output, _quiet, fail_on_issues) =
4321 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
4322 let derived_flags = SecurityDerivedFlagState {
4323 output,
4324 json_style: dispatch.json_style,
4325 ci: cli.ci,
4326 fail_on_issues,
4327 sarif_file: cli.sarif_file.as_deref(),
4328 summary: cli.summary,
4329 explain: cli.explain,
4330 runtime_coverage: runtime_coverage.as_deref(),
4331 min_invocations_hot,
4332 file: file.as_slice(),
4333 gate,
4334 surface,
4335 };
4336 if let Some(code) = try_run_security_survivors(subcommand.as_ref(), &derived_flags) {
4337 return code;
4338 }
4339
4340 let scoped_files = scoped_security_files(&file, subcommand.as_ref());
4341 run_security_blind_spots_or_default(
4342 dispatch,
4343 &SecurityRunInputs {
4344 scoped_files: &scoped_files,
4345 subcommand: &subcommand,
4346 runtime_coverage: runtime_coverage.as_deref(),
4347 min_invocations_hot,
4348 gate,
4349 surface,
4350 scope,
4351 },
4352 &derived_flags,
4353 )
4354}
4355
4356struct SecurityRunInputs<'a> {
4359 scoped_files: &'a [PathBuf],
4360 subcommand: &'a Option<SecuritySubcommand>,
4361 runtime_coverage: Option<&'a Path>,
4362 min_invocations_hot: u64,
4363 gate: Option<security::SecurityGateMode>,
4364 surface: bool,
4365 scope: Option<PathBuf>,
4366}
4367
4368fn run_security_blind_spots_or_default(
4370 dispatch: &DispatchContext<'_>,
4371 inputs: &SecurityRunInputs<'_>,
4372 derived_flags: &SecurityDerivedFlagState<'_>,
4373) -> ExitCode {
4374 let cli = dispatch.cli;
4375 let (output, quiet, fail_on_issues) =
4376 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
4377 let opts = security::SecurityOptions {
4378 root: dispatch.root,
4379 config_path: &cli.config,
4380 output,
4381 json_style: dispatch.json_style,
4382 no_cache: cli.no_cache,
4383 threads: dispatch.threads,
4384 quiet,
4385 allow_remote_extends: cli.allow_remote_extends,
4386 fail_on_issues,
4387 sarif_file: cli.sarif_file.as_deref(),
4388 summary: cli.summary,
4389 changed_since: cli.changed_since.as_deref(),
4390 use_shared_diff_index: true,
4391 workspace: cli.workspace.as_deref(),
4392 changed_workspaces: cli.changed_workspaces.as_deref(),
4393 file: inputs.scoped_files,
4394 surface: inputs.surface,
4395 scope: inputs.scope.clone(),
4396 gate: inputs.gate,
4397 runtime_coverage: inputs.runtime_coverage,
4398 min_invocations_hot: inputs.min_invocations_hot,
4399 explain: cli.explain,
4400 };
4401 if matches!(
4402 inputs.subcommand,
4403 Some(SecuritySubcommand::BlindSpots { .. })
4404 ) {
4405 if let Some(code) = validate_security_blind_spots_flags(derived_flags) {
4406 return code;
4407 }
4408 security::run_blind_spots(&opts)
4409 } else {
4410 security::run(&opts)
4411 }
4412}
4413
4414fn try_run_security_survivors(
4417 subcommand: Option<&SecuritySubcommand>,
4418 flags: &SecurityDerivedFlagState<'_>,
4419) -> Option<ExitCode> {
4420 let Some(SecuritySubcommand::Survivors {
4421 candidates,
4422 verdicts,
4423 require_verdict_for_each_candidate,
4424 }) = subcommand
4425 else {
4426 return None;
4427 };
4428 if let Some(code) = validate_security_survivors_flags(flags) {
4429 return Some(code);
4430 }
4431 Some(security::run_survivors(
4432 &security::SecuritySurvivorsOptions {
4433 output: flags.output,
4434 json_style: flags.json_style,
4435 candidates,
4436 verdicts,
4437 require_verdict_for_each_candidate: *require_verdict_for_each_candidate,
4438 },
4439 ))
4440}
4441
4442fn scoped_security_files(
4444 file: &[PathBuf],
4445 subcommand: Option<&SecuritySubcommand>,
4446) -> Vec<PathBuf> {
4447 let mut scoped_files = file.to_vec();
4448 if let Some(SecuritySubcommand::BlindSpots {
4449 file: blind_spot_files,
4450 }) = subcommand
4451 {
4452 scoped_files.extend(blind_spot_files.iter().cloned());
4453 }
4454 scoped_files
4455}
4456
4457struct SecurityDerivedFlagState<'a> {
4458 output: fallow_config::OutputFormat,
4459 json_style: json_style::JsonStyle,
4460 ci: bool,
4461 fail_on_issues: bool,
4462 sarif_file: Option<&'a Path>,
4463 summary: bool,
4464 explain: bool,
4465 runtime_coverage: Option<&'a Path>,
4466 min_invocations_hot: u64,
4467 file: &'a [PathBuf],
4468 gate: Option<security::SecurityGateMode>,
4469 surface: bool,
4470}
4471
4472fn validate_security_survivors_flags(flags: &SecurityDerivedFlagState<'_>) -> Option<ExitCode> {
4473 let flag = if flags.ci {
4474 Some("--ci")
4475 } else if flags.fail_on_issues {
4476 Some("--fail-on-issues")
4477 } else if flags.sarif_file.is_some() {
4478 Some("--sarif-file")
4479 } else if flags.summary {
4480 Some("--summary")
4481 } else if flags.explain {
4482 Some("--explain")
4483 } else if flags.runtime_coverage.is_some() {
4484 Some("--runtime-coverage")
4485 } else if flags.min_invocations_hot != DEFAULT_MIN_INVOCATIONS_HOT {
4486 Some("--min-invocations-hot")
4487 } else if !flags.file.is_empty() {
4488 Some("--file")
4489 } else if flags.gate.is_some() {
4490 Some("--gate")
4491 } else if flags.surface {
4492 Some("--surface")
4493 } else {
4494 None
4495 }?;
4496 Some(emit_error(
4497 &format!("{flag} is not valid with `fallow security survivors`."),
4498 2,
4499 flags.output,
4500 ))
4501}
4502
4503fn validate_security_blind_spots_flags(flags: &SecurityDerivedFlagState<'_>) -> Option<ExitCode> {
4504 let flag = if flags.ci {
4505 Some("--ci")
4506 } else if flags.fail_on_issues {
4507 Some("--fail-on-issues")
4508 } else if flags.sarif_file.is_some() {
4509 Some("--sarif-file")
4510 } else if flags.summary {
4511 Some("--summary")
4512 } else if flags.explain {
4513 Some("--explain")
4514 } else if flags.runtime_coverage.is_some() {
4515 Some("--runtime-coverage")
4516 } else if flags.min_invocations_hot != DEFAULT_MIN_INVOCATIONS_HOT {
4517 Some("--min-invocations-hot")
4518 } else if flags.gate.is_some() {
4519 Some("--gate")
4520 } else if flags.surface {
4521 Some("--surface")
4522 } else {
4523 None
4524 }?;
4525 Some(emit_error(
4526 &format!("{flag} is not valid with `fallow security blind-spots`."),
4527 2,
4528 flags.output,
4529 ))
4530}
4531
4532fn dispatch_dupes_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4533 let Command::Dupes {
4534 mode,
4535 near,
4536 min_tokens,
4537 min_lines,
4538 min_occurrences,
4539 threshold,
4540 skip_local,
4541 cross_language,
4542 ignore_imports,
4543 no_ignore_imports,
4544 top,
4545 no_fragments,
4546 trace,
4547 path,
4548 } = command
4549 else {
4550 unreachable!("dupes dispatcher only handles dupes commands");
4551 };
4552
4553 let scope = match crate::scope_path::resolve_command_scope(dispatch.root, dispatch.output, path)
4554 {
4555 Ok(scope) => scope.map(|resolved| resolved.absolute),
4556 Err(code) => return code,
4557 };
4558
4559 dispatch_dupes(
4560 dispatch,
4561 &DupesDispatchArgs {
4562 mode,
4563 near,
4564 min_tokens,
4565 min_lines,
4566 min_occurrences,
4567 threshold,
4568 skip_local,
4569 cross_language,
4570 ignore_imports,
4571 no_ignore_imports,
4572 top,
4573 no_fragments,
4574 trace,
4575 scope,
4576 },
4577 )
4578}
4579
4580fn dispatch_agent_command(dispatch: &DispatchContext<'_>, subcommand: AgentCli) -> ExitCode {
4581 run_agent_command(
4582 dispatch.root,
4583 dispatch.cli.root.is_some(),
4584 subcommand,
4585 dispatch.output,
4586 dispatch.json_style,
4587 )
4588}
4589
4590fn dispatch_init_command(command: Command, root: &Path, quiet: bool) -> ExitCode {
4591 let Command::Init {
4592 toml,
4593 agents,
4594 hooks,
4595 branch,
4596 decline,
4597 } = command
4598 else {
4599 unreachable!("init dispatcher only handles init commands");
4600 };
4601
4602 init::run_init(&init::InitOptions {
4603 root,
4604 use_toml: toml,
4605 agents,
4606 hooks,
4607 branch: branch.as_deref(),
4608 decline,
4609 quiet,
4610 })
4611}
4612
4613fn dispatch_fix_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4614 let Command::Fix {
4615 dry_run,
4616 yes,
4617 no_create_config,
4618 path,
4619 } = command
4620 else {
4621 unreachable!("fix dispatcher only handles fix commands");
4622 };
4623
4624 let scope = match crate::scope_path::resolve_command_scope(
4625 dispatch.root,
4626 dispatch.output,
4627 path.clone(),
4628 ) {
4629 Ok(scope) => scope.map(|resolved| resolved.absolute),
4630 Err(code) => return code,
4631 };
4632
4633 dispatch_fix(
4634 dispatch,
4635 &FixDispatchArgs {
4636 dry_run: *dry_run,
4637 yes: *yes,
4638 no_create_config: *no_create_config,
4639 scope,
4640 },
4641 )
4642}
4643
4644fn dispatch_list_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4645 match command {
4646 Command::Workspaces => dispatch_list(dispatch, &ListDispatchArgs::workspaces()),
4647 Command::List {
4648 entry_points,
4649 files,
4650 plugins,
4651 boundaries,
4652 workspaces,
4653 path,
4654 } => {
4655 let scope = match crate::scope_path::resolve_command_scope(
4656 dispatch.root,
4657 dispatch.output,
4658 path.clone(),
4659 ) {
4660 Ok(scope) => scope.map(|resolved| resolved.absolute),
4661 Err(code) => return code,
4662 };
4663 dispatch_list(
4664 dispatch,
4665 &ListDispatchArgs {
4666 entry_points: *entry_points,
4667 files: *files,
4668 plugins: *plugins,
4669 boundaries: *boundaries,
4670 workspaces: *workspaces,
4671 scope,
4672 },
4673 )
4674 }
4675 _ => unreachable!("list dispatcher only handles list commands"),
4676 }
4677}
4678
4679fn dispatch_migrate_command(command: Command, root: &Path) -> ExitCode {
4680 let Command::Migrate {
4681 toml,
4682 jsonc,
4683 dry_run,
4684 from,
4685 } = command
4686 else {
4687 unreachable!("migrate dispatcher only handles migrate commands");
4688 };
4689
4690 migrate::run_migrate(root, toml, jsonc, dry_run, from.as_deref())
4691}
4692
4693fn dispatch_license_command(
4694 subcommand: LicenseCli,
4695 output: fallow_config::OutputFormat,
4696 json_style: json_style::JsonStyle,
4697) -> ExitCode {
4698 license::run(&map_license_subcommand(subcommand), output, json_style)
4699}
4700
4701fn dispatch_ci_template_command(subcommand: CiTemplateCli) -> ExitCode {
4702 match subcommand {
4703 CiTemplateCli::Gitlab { vendor, force } => {
4704 ci_template::run_gitlab_template(&ci_template::GitlabTemplateOptions {
4705 vendor_dir: vendor,
4706 force,
4707 })
4708 }
4709 }
4710}
4711
4712fn dispatch_coverage_command(dispatch: &DispatchContext<'_>, subcommand: &CoverageCli) -> ExitCode {
4713 let cli = dispatch.cli;
4714 coverage::run(
4715 map_coverage_subcommand(subcommand, cli.explain),
4716 &coverage::RunContext {
4717 root: dispatch.root,
4718 config_path: &cli.config,
4719 output: dispatch.output,
4720 json_style: dispatch.json_style,
4721 quiet: dispatch.quiet,
4722 no_cache: cli.no_cache,
4723 threads: dispatch.threads,
4724 explain: cli.explain,
4725 allow_remote_extends: cli.allow_remote_extends,
4726 },
4727 )
4728}
4729
4730fn dispatch_health_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4731 let Command::Health {
4732 max_cyclomatic,
4733 max_cognitive,
4734 max_crap,
4735 top,
4736 sort,
4737 complexity,
4738 complexity_breakdown,
4739 file_scores,
4740 coverage_gaps,
4741 hotspots,
4742 ownership,
4743 ownership_emails,
4744 targets,
4745 type_coupling,
4746 css,
4747 effort,
4748 score,
4749 min_score,
4750 min_severity,
4751 report_only,
4752 since,
4753 min_commits,
4754 save_snapshot,
4755 trend,
4756 coverage,
4757 coverage_root,
4758 runtime_coverage,
4759 min_invocations_hot,
4760 min_observation_volume,
4761 low_traffic_threshold,
4762 path,
4763 } = command
4764 else {
4765 unreachable!("health dispatcher only handles health commands");
4766 };
4767
4768 let scope = match crate::scope_path::resolve_command_scope(dispatch.root, dispatch.output, path)
4769 {
4770 Ok(scope) => scope.map(|resolved| resolved.absolute),
4771 Err(code) => return code,
4772 };
4773
4774 let ownership = ownership || ownership_emails.is_some();
4775 let hotspots = hotspots || ownership;
4776 let args = HealthDispatchArgs {
4777 max_cyclomatic,
4778 max_cognitive,
4779 max_crap,
4780 top,
4781 sort,
4782 complexity,
4783 complexity_breakdown,
4784 file_scores,
4785 coverage_gaps,
4786 hotspots,
4787 ownership,
4788 ownership_emails: ownership_emails.map(EmailModeArg::to_config),
4789 targets,
4790 type_coupling,
4791 css,
4792 effort,
4793 score,
4794 min_score,
4795 min_severity: min_severity.map(HealthSeverityCli::to_health_severity),
4796 report_only,
4797 since: since.as_deref(),
4798 min_commits,
4799 save_snapshot: save_snapshot.as_ref(),
4800 trend,
4801 fail_on_stale_baseline: dispatch.cli.fail_on_stale_baseline,
4802 coverage: coverage.as_deref(),
4803 coverage_root: coverage_root.as_deref(),
4804 runtime_coverage: runtime_coverage.as_deref(),
4805 min_invocations_hot,
4806 min_observation_volume,
4807 low_traffic_threshold,
4808 scope,
4809 };
4810 dispatch_health(dispatch, &args)
4811}
4812
4813fn dispatch_setup_hooks_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4814 let Command::SetupHooks {
4815 agent,
4816 dry_run,
4817 force,
4818 user,
4819 gitignore_claude,
4820 uninstall,
4821 } = command
4822 else {
4823 unreachable!("setup-hooks dispatcher only handles setup-hooks commands");
4824 };
4825
4826 eprintln!(
4827 "warning: `fallow setup-hooks` is deprecated and will be removed in the next major; use `fallow agent install` or `fallow hooks install --target agent`."
4828 );
4829 setup_hooks::run_setup_hooks(&setup_hooks::SetupHooksOptions {
4830 root: dispatch.root,
4831 agent: *agent,
4832 dry_run: *dry_run,
4833 force: *force,
4834 user: *user,
4835 gitignore_claude: *gitignore_claude,
4836 uninstall: *uninstall,
4837 })
4838}
4839
4840fn dispatch_audit_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4841 let Command::Audit {
4842 production_dead_code,
4843 production_health,
4844 production_dupes,
4845 dead_code_baseline,
4846 health_baseline,
4847 dupes_baseline,
4848 max_crap,
4849 coverage,
4850 coverage_root,
4851 no_css,
4852 css_deep,
4853 no_css_deep,
4854 gate,
4855 runtime_coverage,
4856 min_invocations_hot,
4857 gate_marker,
4858 brief,
4859 max_decisions,
4860 walkthrough_guide,
4861 walkthrough_file,
4862 walkthrough,
4863 mark_viewed,
4864 show_cleared,
4865 show_deprioritized,
4866 path,
4867 } = command
4868 else {
4869 unreachable!("audit dispatcher only handles audit commands");
4870 };
4871
4872 let brief = brief || walkthrough_guide || walkthrough || walkthrough_file.is_some();
4875
4876 let scope = match crate::scope_path::resolve_command_scope(dispatch.root, dispatch.output, path)
4877 {
4878 Ok(scope) => scope.map(|resolved| resolved.absolute),
4879 Err(code) => return code,
4880 };
4881
4882 dispatch_audit(
4883 dispatch,
4884 &AuditDispatchArgs {
4885 production_dead_code,
4886 production_health,
4887 production_dupes,
4888 dead_code_baseline,
4889 health_baseline,
4890 dupes_baseline,
4891 max_crap,
4892 coverage,
4893 coverage_root,
4894 no_css,
4895 css_deep,
4896 no_css_deep,
4897 gate,
4898 runtime_coverage,
4899 min_invocations_hot,
4900 gate_marker,
4901 brief,
4902 max_decisions,
4903 walkthrough_guide,
4904 walkthrough_file,
4905 walkthrough,
4906 mark_viewed,
4907 show_cleared,
4908 show_deprioritized,
4909 scope,
4910 },
4911 )
4912}
4913
4914fn dispatch_audit_cache_command(
4915 dispatch: &DispatchContext<'_>,
4916 subcommand: &AuditCacheCli,
4917) -> ExitCode {
4918 match subcommand {
4919 AuditCacheCli::Remove { dry_run, yes } => {
4920 if !*dry_run && !*yes && !std::io::stdin().is_terminal() {
4921 return emit_error(
4922 "audit-cache remove requires --yes (or --force) in non-interactive environments. Use --dry-run to preview removal first, then pass --yes to confirm.",
4923 2,
4924 dispatch.output,
4925 );
4926 }
4927 match base_worktree::remove_reusable_audit_caches(dispatch.root, *dry_run) {
4928 Ok(report) => {
4929 let action = if *dry_run { "would remove" } else { "removed" };
4930 if matches!(dispatch.output, fallow_config::OutputFormat::Json) {
4931 let value = serde_json::json!({
4932 "kind": "audit-cache-remove",
4933 "schema_version": 1,
4934 "command": "audit-cache remove",
4935 "root": dispatch.root,
4936 "dry_run": report.dry_run,
4937 "found": report.found,
4938 "would_remove": report.found.saturating_sub(report.skipped),
4939 "removed": report.removed,
4940 "skipped": report.skipped,
4941 "complete": report.skipped == 0,
4942 });
4943 let output_code = report::emit_report_json(
4944 &value,
4945 "audit cache removal",
4946 dispatch.json_style,
4947 );
4948 if output_code != ExitCode::SUCCESS {
4949 return output_code;
4950 }
4951 } else if !dispatch.quiet {
4952 println!(
4953 "audit cache: {action} {}, skipped {} for {}",
4954 if *dry_run {
4955 report.found.saturating_sub(report.skipped)
4956 } else {
4957 report.removed
4958 },
4959 report.skipped,
4960 dispatch.root.display(),
4961 );
4962 }
4963 if report.skipped == 0 {
4964 ExitCode::SUCCESS
4965 } else {
4966 ExitCode::from(2)
4967 }
4968 }
4969 Err(error) => emit_error(
4970 &format!(
4971 "failed to remove audit caches for {}: {error}",
4972 dispatch.root.display()
4973 ),
4974 2,
4975 dispatch.output,
4976 ),
4977 }
4978 }
4979 AuditCacheCli::Prune {
4980 dry_run,
4981 max_age_days,
4982 } => audit_cache_prune::run_audit_cache_prune(&audit_cache_prune::AuditCachePruneOptions {
4983 root: dispatch.root,
4984 config_path: dispatch.cli.config.as_ref(),
4985 allow_remote_extends: dispatch.cli.allow_remote_extends,
4986 dry_run: *dry_run,
4987 max_age_days: *max_age_days,
4988 output: dispatch.output,
4989 json_style: dispatch.json_style,
4990 quiet: dispatch.quiet,
4991 }),
4992 }
4993}
4994
4995fn dispatch_flags_command(dispatch: &DispatchContext<'_>, top: Option<usize>) -> ExitCode {
4996 let cli = dispatch.cli;
4997 let root = dispatch.root;
4998 let output = dispatch.output;
4999 let quiet = dispatch.quiet;
5000 let threads = dispatch.threads;
5001 let production = match resolve_production_modes(cli, root, output, false, false, false) {
5002 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::DeadCode),
5003 Err(code) => return code,
5004 };
5005 flags::run_flags(&flags::FlagsOptions {
5006 root,
5007 config_path: &cli.config,
5008 output,
5009 json_style: dispatch.json_style,
5010 no_cache: cli.no_cache,
5011 threads,
5012 quiet,
5013 allow_remote_extends: cli.allow_remote_extends,
5014 production,
5015 workspace: cli.workspace.as_deref(),
5016 changed_workspaces: cli.changed_workspaces.as_deref(),
5017 changed_since: cli.changed_since.as_deref(),
5018 explain: cli.explain,
5019 top,
5020 })
5021}
5022
5023fn dispatch_suppressions_command(
5024 dispatch: &DispatchContext<'_>,
5025 file: &[std::path::PathBuf],
5026) -> ExitCode {
5027 let cli = dispatch.cli;
5028 let root = dispatch.root;
5029 let output = dispatch.output;
5030 let production = match resolve_production_modes(cli, root, output, false, false, false) {
5031 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::DeadCode),
5032 Err(code) => return code,
5033 };
5034 suppressions::run_suppressions(&suppressions::SuppressionsOptions {
5035 root,
5036 config_path: &cli.config,
5037 output,
5038 json_style: dispatch.json_style,
5039 no_cache: cli.no_cache,
5040 threads: dispatch.threads,
5041 quiet: dispatch.quiet,
5042 allow_remote_extends: cli.allow_remote_extends,
5043 production,
5044 workspace: cli.workspace.as_deref(),
5045 changed_workspaces: cli.changed_workspaces.as_deref(),
5046 changed_since: cli.changed_since.as_deref(),
5047 file,
5048 })
5049}
5050
5051fn dispatch_guard_command(dispatch: &DispatchContext<'_>, files: &[String]) -> ExitCode {
5052 guard::run_guard(&guard::GuardOptions {
5053 root: dispatch.root,
5054 config_path: &dispatch.cli.config,
5055 output: dispatch.output,
5056 json_style: dispatch.json_style,
5057 quiet: dispatch.quiet,
5058 allow_remote_extends: dispatch.cli.allow_remote_extends,
5059 files,
5060 })
5061}
5062
5063fn dispatch_rule_pack_command(dispatch: &DispatchContext<'_>, subcommand: RulePackCli) -> ExitCode {
5064 let ctx = rule_pack::RulePackContext {
5065 root: dispatch.root,
5066 config_path: &dispatch.cli.config,
5067 output: dispatch.output,
5068 json_style: dispatch.json_style,
5069 quiet: dispatch.quiet,
5070 no_cache: dispatch.cli.no_cache,
5071 threads: Some(dispatch.threads),
5072 allow_remote_extends: dispatch.cli.allow_remote_extends,
5073 };
5074 rule_pack::run(&map_rule_pack_subcommand(subcommand), &ctx)
5075}
5076
5077fn map_rule_pack_subcommand(subcommand: RulePackCli) -> rule_pack::RulePackSubcommand {
5078 match subcommand {
5079 RulePackCli::Init {
5080 name,
5081 template,
5082 dir,
5083 no_config,
5084 } => rule_pack::RulePackSubcommand::Init(rule_pack::InitArgs {
5085 name,
5086 template,
5087 dir,
5088 no_config,
5089 }),
5090 RulePackCli::List => rule_pack::RulePackSubcommand::List,
5091 RulePackCli::Test { pack } => {
5092 rule_pack::RulePackSubcommand::Test(rule_pack::TestArgs { pack })
5093 }
5094 RulePackCli::Schema => rule_pack::RulePackSubcommand::Schema,
5095 }
5096}
5097
5098fn map_license_subcommand(sub: LicenseCli) -> license::LicenseSubcommand {
5099 match sub {
5100 LicenseCli::Activate {
5101 jwt,
5102 from_file,
5103 stdin,
5104 trial,
5105 email,
5106 } => license::LicenseSubcommand::Activate(license::ActivateArgs {
5107 raw_jwt: jwt,
5108 from_file,
5109 from_stdin: stdin,
5110 trial,
5111 email,
5112 }),
5113 LicenseCli::Status => license::LicenseSubcommand::Status,
5114 LicenseCli::Refresh { api_key } => {
5115 license::LicenseSubcommand::Refresh(license::RefreshArgs { api_key })
5116 }
5117 LicenseCli::Deactivate => license::LicenseSubcommand::Deactivate,
5118 }
5119}
5120
5121fn map_telemetry_subcommand(sub: TelemetryCli) -> telemetry::TelemetryCommand {
5122 match sub {
5123 TelemetryCli::Status => telemetry::TelemetryCommand::Status,
5124 TelemetryCli::Enable => telemetry::TelemetryCommand::Enable,
5125 TelemetryCli::Disable => telemetry::TelemetryCommand::Disable,
5126 TelemetryCli::Inspect { example } => telemetry::TelemetryCommand::Inspect { example },
5127 }
5128}
5129
5130fn map_ci_subcommand(sub: CiCli) -> ci::CiCommand {
5131 match sub {
5132 command @ CiCli::PlanPrComment { .. } => map_ci_plan_pr_comment(command),
5133 command @ CiCli::PostPrComment { .. } => map_ci_post_pr_comment(command),
5134 command @ CiCli::PostReview { .. } => map_ci_post_review(command),
5135 command @ CiCli::PostCheckRun { .. } => map_ci_post_check_run(command),
5136 command @ CiCli::ReconcileReview { .. } => map_ci_reconcile_review(command),
5137 }
5138}
5139
5140fn map_ci_plan_pr_comment(command: CiCli) -> ci::CiCommand {
5141 let CiCli::PlanPrComment {
5142 body,
5143 marker_id,
5144 clean,
5145 existing_comment_id,
5146 existing_body,
5147 } = command
5148 else {
5149 unreachable!("ci plan-pr-comment mapper called with different variant");
5150 };
5151
5152 ci::CiCommand::PlanPrComment {
5153 body,
5154 marker_id,
5155 clean,
5156 existing_comment_id,
5157 existing_body,
5158 }
5159}
5160
5161fn map_ci_post_pr_comment(command: CiCli) -> ci::CiCommand {
5162 let CiCli::PostPrComment {
5163 provider,
5164 pr,
5165 mr,
5166 body,
5167 envelope,
5168 marker_id,
5169 clean,
5170 repo,
5171 project_id,
5172 api_url,
5173 dry_run,
5174 } = command
5175 else {
5176 unreachable!("ci post-pr-comment mapper called with different variant");
5177 };
5178
5179 ci::CiCommand::PostPrComment {
5180 provider: map_ci_provider(provider),
5181 target: pr.or(mr),
5182 body,
5183 envelope,
5184 marker_id,
5185 clean,
5186 repo,
5187 project_id,
5188 api_url,
5189 dry_run,
5190 }
5191}
5192
5193fn map_ci_post_review(command: CiCli) -> ci::CiCommand {
5194 let CiCli::PostReview {
5195 provider,
5196 pr,
5197 mr,
5198 envelope,
5199 repo,
5200 project_id,
5201 api_url,
5202 dry_run,
5203 } = command
5204 else {
5205 unreachable!("ci post-review mapper called with different variant");
5206 };
5207
5208 ci::CiCommand::PostReview {
5209 provider: map_ci_provider(provider),
5210 target: pr.or(mr),
5211 envelope,
5212 repo,
5213 project_id,
5214 api_url,
5215 dry_run,
5216 }
5217}
5218
5219fn map_ci_post_check_run(command: CiCli) -> ci::CiCommand {
5220 let CiCli::PostCheckRun {
5221 provider,
5222 decision,
5223 repo,
5224 head_sha,
5225 api_url,
5226 split_gates,
5227 dry_run,
5228 } = command
5229 else {
5230 unreachable!("ci post-check-run mapper called with different variant");
5231 };
5232
5233 ci::CiCommand::PostCheckRun {
5234 provider: map_ci_provider(provider),
5235 decision,
5236 repo,
5237 head_sha,
5238 api_url,
5239 split_gates,
5240 dry_run,
5241 }
5242}
5243
5244fn map_ci_reconcile_review(command: CiCli) -> ci::CiCommand {
5245 let CiCli::ReconcileReview {
5246 provider,
5247 pr,
5248 mr,
5249 envelope,
5250 repo,
5251 project_id,
5252 api_url,
5253 dry_run,
5254 } = command
5255 else {
5256 unreachable!("ci reconcile-review mapper called with different variant");
5257 };
5258
5259 ci::CiCommand::ReconcileReview {
5260 provider: map_ci_provider(provider),
5261 target: pr.or(mr),
5262 envelope,
5263 repo,
5264 project_id,
5265 api_url,
5266 dry_run,
5267 }
5268}
5269
5270fn map_ci_provider(provider: CiProviderArg) -> ci::CiProvider {
5271 match provider {
5272 CiProviderArg::Github => ci::CiProvider::Github,
5273 CiProviderArg::Gitlab => ci::CiProvider::Gitlab,
5274 }
5275}
5276
5277fn map_coverage_subcommand(sub: &CoverageCli, explain: bool) -> coverage::CoverageSubcommand {
5278 match sub {
5279 CoverageCli::Setup {
5280 yes,
5281 non_interactive,
5282 json,
5283 } => map_coverage_setup(*yes, *non_interactive, *json, explain),
5284 CoverageCli::Analyze { .. } => map_coverage_analyze(sub),
5285 CoverageCli::UploadInventory { .. } => map_coverage_upload_inventory(sub),
5286 CoverageCli::UploadSourceMaps { .. } => map_coverage_upload_source_maps(sub),
5287 CoverageCli::UploadStaticFindings { .. } => map_coverage_upload_static_findings(sub),
5288 }
5289}
5290
5291fn map_coverage_setup(
5292 yes: bool,
5293 non_interactive: bool,
5294 json: bool,
5295 explain: bool,
5296) -> coverage::CoverageSubcommand {
5297 coverage::CoverageSubcommand::Setup(coverage::SetupArgs {
5298 yes,
5299 non_interactive: non_interactive || json,
5300 json,
5301 explain,
5302 })
5303}
5304
5305fn map_coverage_analyze(sub: &CoverageCli) -> coverage::CoverageSubcommand {
5306 let CoverageCli::Analyze {
5307 runtime_coverage,
5308 cloud,
5309 api_key,
5310 api_endpoint,
5311 repo,
5312 project_id,
5313 coverage_period,
5314 environment,
5315 commit_sha,
5316 production,
5317 min_invocations_hot,
5318 min_observation_volume,
5319 low_traffic_threshold,
5320 top,
5321 blast_radius,
5322 importance,
5323 debug_unmatched,
5324 } = sub
5325 else {
5326 unreachable!("coverage analyze mapper called with non-analyze variant");
5327 };
5328 coverage::CoverageSubcommand::Analyze(coverage::AnalyzeArgs {
5329 runtime_coverage: runtime_coverage.clone(),
5330 cloud: *cloud,
5331 api_key: api_key.clone(),
5332 api_endpoint: api_endpoint.clone(),
5333 repo: repo.clone(),
5334 project_id: project_id.clone(),
5335 coverage_period: *coverage_period,
5336 environment: environment.clone(),
5337 commit_sha: commit_sha.clone(),
5338 production: *production,
5339 min_invocations_hot: *min_invocations_hot,
5340 min_observation_volume: *min_observation_volume,
5341 low_traffic_threshold: *low_traffic_threshold,
5342 top: *top,
5343 blast_radius: *blast_radius,
5344 importance: *importance,
5345 debug_unmatched: *debug_unmatched,
5346 })
5347}
5348
5349fn map_coverage_upload_inventory(sub: &CoverageCli) -> coverage::CoverageSubcommand {
5350 let CoverageCli::UploadInventory {
5351 api_key,
5352 api_endpoint,
5353 project_id,
5354 git_sha,
5355 allow_dirty,
5356 exclude_paths,
5357 path_prefix,
5358 dry_run,
5359 with_callers,
5360 ignore_upload_errors,
5361 } = sub
5362 else {
5363 unreachable!("coverage inventory mapper called with non-inventory variant");
5364 };
5365 coverage::CoverageSubcommand::UploadInventory(coverage::UploadInventoryArgs {
5366 api_key: api_key.clone(),
5367 api_endpoint: api_endpoint.clone(),
5368 project_id: project_id.clone(),
5369 git_sha: git_sha.clone(),
5370 allow_dirty: *allow_dirty,
5371 exclude_paths: exclude_paths.clone(),
5372 path_prefix: path_prefix.clone(),
5373 dry_run: *dry_run,
5374 with_callers: *with_callers,
5375 ignore_upload_errors: *ignore_upload_errors,
5376 })
5377}
5378
5379fn map_coverage_upload_source_maps(sub: &CoverageCli) -> coverage::CoverageSubcommand {
5380 let CoverageCli::UploadSourceMaps {
5381 dir,
5382 include,
5383 exclude,
5384 repo,
5385 git_sha,
5386 endpoint,
5387 strip_path,
5388 dry_run,
5389 concurrency,
5390 fail_fast,
5391 } = sub
5392 else {
5393 unreachable!("coverage source-map mapper called with non-source-map variant");
5394 };
5395 coverage::CoverageSubcommand::UploadSourceMaps(coverage::UploadSourceMapsArgs {
5396 dir: dir.clone(),
5397 include: include.clone(),
5398 exclude: exclude.clone(),
5399 repo: repo.clone(),
5400 git_sha: git_sha.clone(),
5401 endpoint: endpoint.clone(),
5402 strip_path: *strip_path,
5403 dry_run: *dry_run,
5404 concurrency: *concurrency,
5405 fail_fast: *fail_fast,
5406 })
5407}
5408
5409fn map_coverage_upload_static_findings(sub: &CoverageCli) -> coverage::CoverageSubcommand {
5410 let CoverageCli::UploadStaticFindings {
5411 api_key,
5412 api_endpoint,
5413 project_id,
5414 git_sha,
5415 allow_dirty,
5416 dry_run,
5417 ignore_upload_errors,
5418 } = sub
5419 else {
5420 unreachable!("coverage static-findings mapper called with non-static variant");
5421 };
5422 coverage::CoverageSubcommand::UploadStaticFindings(coverage::UploadStaticFindingsArgs {
5423 api_key: api_key.clone(),
5424 api_endpoint: api_endpoint.clone(),
5425 project_id: project_id.clone(),
5426 git_sha: git_sha.clone(),
5427 allow_dirty: *allow_dirty,
5428 dry_run: *dry_run,
5429 ignore_upload_errors: *ignore_upload_errors,
5430 })
5431}
5432
5433struct CheckDispatchArgs {
5434 filters: IssueFilters,
5435 trace_opts: TraceOptions,
5436 include_dupes: bool,
5437 type_aware: Option<bool>,
5438 type_aware_project: Vec<std::path::PathBuf>,
5439 type_aware_require: Option<TypeAwareRequireArg>,
5440 top: Option<usize>,
5441 file: Vec<std::path::PathBuf>,
5442 scope: Option<std::path::PathBuf>,
5443}
5444
5445#[derive(Clone)]
5446struct ListDispatchArgs {
5447 entry_points: bool,
5448 files: bool,
5449 plugins: bool,
5450 boundaries: bool,
5451 workspaces: bool,
5452 scope: Option<std::path::PathBuf>,
5453}
5454
5455impl ListDispatchArgs {
5456 fn workspaces() -> Self {
5457 Self {
5458 entry_points: false,
5459 files: false,
5460 plugins: false,
5461 boundaries: false,
5462 workspaces: true,
5463 scope: None,
5464 }
5465 }
5466}
5467
5468fn dispatch_viz(
5469 dispatch: &DispatchContext<'_>,
5470 output_path: Option<&std::path::Path>,
5471 no_open: bool,
5472 format: viz::VizFormat,
5473) -> ExitCode {
5474 let cli = dispatch.cli;
5475 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
5476 Ok(production) => production,
5477 Err(code) => return code,
5478 };
5479 viz::run_viz(&viz::VizOptions {
5480 root: dispatch.root,
5481 config_path: &cli.config,
5482 no_cache: cli.no_cache,
5483 threads: dispatch.threads,
5484 quiet: dispatch.quiet,
5485 production,
5486 allow_remote_extends: cli.allow_remote_extends,
5487 output_path,
5488 no_open,
5489 format,
5490 })
5491}
5492
5493fn dispatch_watch(dispatch: &DispatchContext<'_>, no_clear: bool) -> ExitCode {
5494 let cli = dispatch.cli;
5495 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
5496 Ok(production) => production,
5497 Err(code) => return code,
5498 };
5499 watch::run_watch(&watch::WatchOptions {
5500 root: dispatch.root,
5501 config_path: &cli.config,
5502 output: dispatch.output,
5503 json_style: dispatch.json_style,
5504 no_cache: cli.no_cache,
5505 threads: dispatch.threads,
5506 quiet: dispatch.quiet,
5507 allow_remote_extends: cli.allow_remote_extends,
5508 production,
5509 clear_screen: !no_clear,
5510 explain: cli.explain,
5511 include_entry_exports: cli.include_entry_exports,
5512 type_aware: cli.type_aware_override(),
5513 type_aware_projects: &cli.type_aware_project,
5514 type_aware_require: cli.type_aware_require.map(Into::into),
5515 })
5516}
5517
5518struct FixDispatchArgs {
5519 dry_run: bool,
5520 yes: bool,
5521 no_create_config: bool,
5522 scope: Option<std::path::PathBuf>,
5523}
5524
5525fn dispatch_fix(dispatch: &DispatchContext<'_>, args: &FixDispatchArgs) -> ExitCode {
5526 let cli = dispatch.cli;
5527 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
5528 Ok(production) => production,
5529 Err(code) => return code,
5530 };
5531 fix::run_fix(&fix::FixOptions {
5532 root: dispatch.root,
5533 config_path: &cli.config,
5534 output: dispatch.output,
5535 json_style: dispatch.json_style,
5536 no_cache: cli.no_cache,
5537 threads: dispatch.threads,
5538 quiet: dispatch.quiet,
5539 emit_output: true,
5540 allow_remote_extends: cli.allow_remote_extends,
5541 dry_run: args.dry_run,
5542 yes: args.yes,
5543 production,
5544 no_create_config: args.no_create_config,
5545 type_aware: cli.type_aware_override(),
5546 type_aware_projects: &cli.type_aware_project,
5547 type_aware_require: cli.type_aware_require.map(Into::into),
5548 scope: args.scope.clone(),
5549 })
5550}
5551
5552fn dispatch_list(dispatch: &DispatchContext<'_>, args: &ListDispatchArgs) -> ExitCode {
5553 let cli = dispatch.cli;
5554 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
5555 Ok(production) => production,
5556 Err(code) => return code,
5557 };
5558 list::run_list(&ListOptions {
5559 root: dispatch.root,
5560 config_path: &cli.config,
5561 output: dispatch.output,
5562 json_style: dispatch.json_style,
5563 threads: dispatch.threads,
5564 no_cache: cli.no_cache,
5565 entry_points: args.entry_points,
5566 files: args.files,
5567 plugins: args.plugins,
5568 boundaries: args.boundaries,
5569 workspaces: args.workspaces,
5570 production,
5571 allow_remote_extends: cli.allow_remote_extends,
5572 scope: args.scope.clone(),
5573 })
5574}
5575
5576fn dispatch_check(dispatch: &DispatchContext<'_>, args: &CheckDispatchArgs) -> ExitCode {
5577 let cli = dispatch.cli;
5578 let (output, quiet, fail_on_issues) =
5579 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
5580 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
5581 Ok(production) => production,
5582 Err(code) => return code,
5583 };
5584 if let Some(code) = validate_type_aware_check_options(dispatch, args) {
5585 return code;
5586 }
5587 check::run_check(&CheckOptions {
5588 root: dispatch.root,
5589 config_path: &cli.config,
5590 output,
5591 json_style: dispatch.json_style,
5592 no_cache: cli.no_cache,
5593 threads: dispatch.threads,
5594 quiet,
5595 allow_remote_extends: cli.allow_remote_extends,
5596 fail_on_issues,
5597 filters: &args.filters,
5598 changed_since: cli.changed_since.as_deref(),
5599 diff_index: None,
5600 use_shared_diff_index: true,
5601 baseline: cli.baseline.as_deref(),
5602 baseline_flag: "--baseline",
5603 save_baseline: cli.save_baseline.as_deref(),
5604 fail_on_stale_baseline: cli.fail_on_stale_baseline,
5605 sarif_file: cli.sarif_file.as_deref(),
5606 production,
5607 production_override: Some(production),
5608 workspace: cli.workspace.as_deref(),
5609 changed_workspaces: cli.changed_workspaces.as_deref(),
5610 group_by: cli.group_by,
5611 include_dupes: args.include_dupes,
5612 type_aware: args.type_aware,
5613 type_aware_config_override: None,
5614 type_aware_projects: &args.type_aware_project,
5615 type_aware_require: args.type_aware_require.map(Into::into),
5616 trace_opts: &args.trace_opts,
5617 explain: cli.explain,
5618 top: args.top,
5619 file: &args.file,
5620 scope: args.scope.clone(),
5621 include_entry_exports: cli.include_entry_exports,
5622 summary: cli.summary,
5623 regression_opts: dispatch.regression_opts(
5624 cli.changed_since.is_some()
5625 || cli.workspace.is_some()
5626 || cli.changed_workspaces.is_some()
5627 || !args.file.is_empty()
5628 || args.scope.is_some(),
5629 ),
5630 retain_modules_for_health: false,
5631 defer_performance: false,
5632 analysis_snapshot: fallow_config::AnalysisSnapshot::Current,
5633 explain_skipped: cli.explain_skipped,
5634 })
5635}
5636
5637fn validate_type_aware_check_options(
5638 dispatch: &DispatchContext<'_>,
5639 args: &CheckDispatchArgs,
5640) -> Option<ExitCode> {
5641 let output = dispatch.output;
5642 if !args.type_aware_project.is_empty() && args.type_aware != Some(true) {
5643 return Some(emit_error(
5644 "--type-aware-project requires --type-aware",
5645 2,
5646 output,
5647 ));
5648 }
5649 if args.type_aware_require.is_some() && args.type_aware != Some(true) {
5650 return Some(emit_error(
5651 "--type-aware-require requires --type-aware",
5652 2,
5653 output,
5654 ));
5655 }
5656 if args.trace_opts.symbol_impact.is_some() && args.type_aware != Some(true) {
5657 return Some(emit_error(
5658 "--symbol-impact requires --type-aware",
5659 2,
5660 output,
5661 ));
5662 }
5663 let focused_output = args.trace_opts.trace_export.is_some()
5664 || args.trace_opts.trace_file.is_some()
5665 || args.trace_opts.trace_dependency.is_some()
5666 || args.trace_opts.impact_closure.is_some()
5667 || args.trace_opts.symbol_impact.is_some();
5668 if focused_output
5669 && !matches!(
5670 output,
5671 fallow_config::OutputFormat::Human | fallow_config::OutputFormat::Json
5672 )
5673 {
5674 return Some(emit_error(
5675 "focused trace and impact queries support human and JSON output",
5676 2,
5677 output,
5678 ));
5679 }
5680 if args.type_aware == Some(true)
5681 && !matches!(
5682 output,
5683 fallow_config::OutputFormat::Human
5684 | fallow_config::OutputFormat::Json
5685 | fallow_config::OutputFormat::Sarif
5686 | fallow_config::OutputFormat::Compact
5687 | fallow_config::OutputFormat::Markdown
5688 | fallow_config::OutputFormat::CodeClimate
5689 | fallow_config::OutputFormat::PrCommentGithub
5690 | fallow_config::OutputFormat::PrCommentGitlab
5691 | fallow_config::OutputFormat::ReviewGithub
5692 | fallow_config::OutputFormat::ReviewGitlab
5693 )
5694 {
5695 return Some(emit_error(
5696 "--type-aware supports human, JSON, SARIF, compact, markdown, CodeClimate, PR-comment, and review output; pair presentation formats with the JSON artifact to preserve semantic provenance",
5697 2,
5698 output,
5699 ));
5700 }
5701 None
5702}
5703
5704fn resolve_ignore_imports(ignore_imports: bool, no_ignore_imports: bool) -> Option<bool> {
5710 if no_ignore_imports {
5711 Some(false)
5712 } else if ignore_imports {
5713 Some(true)
5714 } else {
5715 None
5716 }
5717}
5718
5719struct DupesDispatchArgs {
5720 mode: Option<DupesMode>,
5721 near: bool,
5722 min_tokens: Option<usize>,
5723 min_lines: Option<usize>,
5724 min_occurrences: Option<usize>,
5725 threshold: Option<f64>,
5726 skip_local: bool,
5727 cross_language: bool,
5728 ignore_imports: bool,
5729 no_ignore_imports: bool,
5730 top: Option<usize>,
5731 no_fragments: bool,
5732 trace: Option<String>,
5733 scope: Option<std::path::PathBuf>,
5734}
5735
5736fn dispatch_dupes(dispatch: &DispatchContext<'_>, args: &DupesDispatchArgs) -> ExitCode {
5737 let cli = dispatch.cli;
5738 let (output, quiet, _fail_on_issues) =
5739 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
5740 let production = match dispatch.production_for(fallow_config::ProductionAnalysis::Dupes) {
5741 Ok(production) => production,
5742 Err(code) => return code,
5743 };
5744 dupes::run_dupes(&DupesOptions {
5745 root: dispatch.root,
5746 config_path: &cli.config,
5747 output,
5748 json_style: dispatch.json_style,
5749 no_cache: cli.no_cache,
5750 threads: dispatch.threads,
5751 quiet,
5752 allow_remote_extends: cli.allow_remote_extends,
5753 mode: args.mode,
5754 near: args.near,
5755 min_tokens: args.min_tokens,
5756 min_lines: args.min_lines,
5757 min_occurrences: args.min_occurrences,
5758 threshold: args.threshold,
5759 skip_local: args.skip_local,
5760 cross_language: args.cross_language,
5761 ignore_imports: resolve_ignore_imports(args.ignore_imports, args.no_ignore_imports),
5762 top: args.top,
5763 baseline_path: cli.baseline.as_deref(),
5764 baseline_flag: "--baseline",
5765 save_baseline_path: cli.save_baseline.as_deref(),
5766 fail_on_stale_baseline: cli.fail_on_stale_baseline,
5767 production,
5768 production_override: Some(production),
5769 trace: args.trace.as_deref(),
5770 changed_since: cli.changed_since.as_deref(),
5771 diff_index: None,
5772 use_shared_diff_index: true,
5773 changed_files: None,
5774 workspace: cli.workspace.as_deref(),
5775 changed_workspaces: cli.changed_workspaces.as_deref(),
5776 explain: cli.explain,
5777 explain_skipped: cli.explain_skipped,
5778 summary: cli.summary,
5779 group_by: cli.group_by,
5780 performance: cli.performance,
5781 include_fragments: !args.no_fragments,
5782 scope: args.scope.clone(),
5783 })
5784}
5785
5786struct AuditDispatchArgs {
5787 production_dead_code: bool,
5788 production_health: bool,
5789 production_dupes: bool,
5790 dead_code_baseline: Option<PathBuf>,
5791 health_baseline: Option<PathBuf>,
5792 dupes_baseline: Option<PathBuf>,
5793 max_crap: Option<f64>,
5794 coverage: Option<PathBuf>,
5795 coverage_root: Option<PathBuf>,
5796 no_css: bool,
5797 css_deep: bool,
5798 no_css_deep: bool,
5799 gate: Option<AuditGateArg>,
5800 runtime_coverage: Option<PathBuf>,
5801 min_invocations_hot: u64,
5802 gate_marker: Option<String>,
5803 brief: bool,
5804 max_decisions: usize,
5805 walkthrough_guide: bool,
5807 walkthrough_file: Option<PathBuf>,
5810 walkthrough: bool,
5812 mark_viewed: Vec<PathBuf>,
5814 show_cleared: bool,
5816 show_deprioritized: bool,
5818 scope: Option<PathBuf>,
5819}
5820
5821struct ResolvedAuditInputs {
5822 audit_cfg: fallow_config::AuditConfig,
5823 cache_dir: PathBuf,
5824 production: ProductionModes,
5825 dead_code_baseline: Option<PathBuf>,
5826 health_baseline: Option<PathBuf>,
5827 dupes_baseline: Option<PathBuf>,
5828 coverage: Option<PathBuf>,
5832 coverage_root: Option<PathBuf>,
5833}
5834
5835fn dispatch_audit(dispatch: &DispatchContext<'_>, args: &AuditDispatchArgs) -> ExitCode {
5836 let cli = dispatch.cli;
5837 let output = dispatch.output;
5838
5839 if cli.baseline.is_some() || cli.save_baseline.is_some() {
5840 return emit_error(
5841 "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>`)",
5842 2,
5843 output,
5844 );
5845 }
5846
5847 let inputs = match resolve_audit_inputs(dispatch, args) {
5848 Ok(inputs) => inputs,
5849 Err(code) => return code,
5850 };
5851
5852 run_resolved_audit(dispatch, args, &inputs)
5853}
5854
5855fn resolve_audit_inputs(
5856 dispatch: &DispatchContext<'_>,
5857 args: &AuditDispatchArgs,
5858) -> Result<ResolvedAuditInputs, ExitCode> {
5859 let cli = dispatch.cli;
5860 let root = dispatch.root;
5861 let output = dispatch.output;
5862 let config = load_config(
5863 root,
5864 &cli.config,
5865 LoadConfigArgs {
5866 output,
5867 no_cache: cli.no_cache,
5868 threads: dispatch.threads,
5869 production: cli.production,
5870 quiet: dispatch.quiet,
5871 allow_remote_extends: cli.allow_remote_extends,
5872 },
5873 )?;
5874 let cache_dir = config.cache_dir.clone();
5875 let audit_cfg = config.audit;
5876 let production = resolve_production_modes(
5877 cli,
5878 root,
5879 output,
5880 args.production_dead_code,
5881 args.production_health,
5882 args.production_dupes,
5883 )?;
5884 let resolved_dead_code_baseline = resolve_audit_baseline_path(
5885 root,
5886 args.dead_code_baseline.as_deref(),
5887 audit_cfg.dead_code_baseline.as_deref(),
5888 );
5889 let resolved_health_baseline = resolve_audit_baseline_path(
5890 root,
5891 args.health_baseline.as_deref(),
5892 audit_cfg.health_baseline.as_deref(),
5893 );
5894 let resolved_dupes_baseline = resolve_audit_baseline_path(
5895 root,
5896 args.dupes_baseline.as_deref(),
5897 audit_cfg.dupes_baseline.as_deref(),
5898 );
5899 let coverage_inputs = resolve_coverage_inputs(
5900 args.coverage.as_deref(),
5901 args.coverage_root.as_deref(),
5902 output,
5903 || Ok(config.health),
5904 )?;
5905
5906 Ok(ResolvedAuditInputs {
5907 audit_cfg,
5908 cache_dir,
5909 production,
5910 dead_code_baseline: resolved_dead_code_baseline,
5911 health_baseline: resolved_health_baseline,
5912 dupes_baseline: resolved_dupes_baseline,
5913 coverage: coverage_inputs.coverage,
5914 coverage_root: coverage_inputs.coverage_root,
5915 })
5916}
5917
5918fn audit_css_enabled(config: &fallow_config::AuditConfig, args: &AuditDispatchArgs) -> bool {
5919 !args.no_css && config.css.unwrap_or(true)
5920}
5921
5922fn audit_css_deep_enabled(config: &fallow_config::AuditConfig, args: &AuditDispatchArgs) -> bool {
5923 audit_css_enabled(config, args)
5924 && !args.no_css_deep
5925 && (args.css_deep || config.css_deep.unwrap_or(true))
5926}
5927
5928fn run_resolved_audit(
5929 dispatch: &DispatchContext<'_>,
5930 args: &AuditDispatchArgs,
5931 inputs: &ResolvedAuditInputs,
5932) -> ExitCode {
5933 let cli = dispatch.cli;
5934 audit::run_audit_with_type_aware(
5935 &audit::AuditOptions {
5936 root: dispatch.root,
5937 config_path: &cli.config,
5938 cache_dir: &inputs.cache_dir,
5939 output: dispatch.output,
5940 json_style: dispatch.json_style,
5941 no_cache: cli.no_cache,
5942 threads: dispatch.threads,
5943 quiet: dispatch.quiet,
5944 allow_remote_extends: cli.allow_remote_extends,
5945 changed_since: cli.changed_since.as_deref(),
5946 production: cli.production,
5947 production_dead_code: Some(inputs.production.dead_code),
5948 production_health: Some(inputs.production.health),
5949 production_dupes: Some(inputs.production.dupes),
5950 workspace: cli.workspace.as_deref(),
5951 changed_workspaces: cli.changed_workspaces.as_deref(),
5952 explain: cli.explain,
5953 explain_skipped: cli.explain_skipped,
5954 performance: cli.performance,
5955 group_by: cli.group_by,
5956 dead_code_baseline: inputs.dead_code_baseline.as_deref(),
5957 health_baseline: inputs.health_baseline.as_deref(),
5958 dupes_baseline: inputs.dupes_baseline.as_deref(),
5959 health_baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
5960 fail_on_stale_baseline: cli.fail_on_stale_baseline,
5961 max_crap: args.max_crap,
5962 coverage: inputs.coverage.as_deref(),
5963 coverage_root: inputs.coverage_root.as_deref(),
5964 gate: args.gate.map_or(inputs.audit_cfg.gate, Into::into),
5965 include_entry_exports: cli.include_entry_exports,
5966 css: audit_css_enabled(&inputs.audit_cfg, args),
5970 css_deep: audit_css_deep_enabled(&inputs.audit_cfg, args),
5971 runtime_coverage: args.runtime_coverage.as_deref(),
5972 min_invocations_hot: args.min_invocations_hot,
5973 brief: args.brief,
5974 max_decisions: args.max_decisions,
5975 walkthrough_guide: args.walkthrough_guide,
5976 walkthrough: args.walkthrough,
5977 mark_viewed: &args.mark_viewed,
5978 show_cleared: args.show_cleared,
5979 walkthrough_file: args.walkthrough_file.as_deref(),
5980 show_deprioritized: args.show_deprioritized,
5981 scope: args.scope.clone(),
5982 },
5983 args.gate_marker.as_deref(),
5984 audit::AuditTypeAwareOptions {
5985 enabled: cli.type_aware_override(),
5986 config_default: inputs.audit_cfg.type_aware,
5987 projects: &cli.type_aware_project,
5988 require: cli.type_aware_require.map(Into::into),
5989 },
5990 )
5991}
5992
5993fn dispatch_decision_surface(dispatch: &DispatchContext<'_>, max_decisions: usize) -> ExitCode {
5997 let args = decision_surface_audit_args(max_decisions);
5998 let inputs = match resolve_audit_inputs(dispatch, &args) {
5999 Ok(inputs) => inputs,
6000 Err(code) => return code,
6001 };
6002 if dispatch.cli.fail_on_stale_baseline {
6006 for path in [
6007 &inputs.dead_code_baseline,
6008 &inputs.health_baseline,
6009 &inputs.dupes_baseline,
6010 ] {
6011 baseline_gate::note_stood_down(
6012 path.as_deref(),
6013 true,
6014 "decision-surface renders a brief without exit gates",
6015 );
6016 }
6017 }
6018 audit::run_decision_surface(&decision_surface_audit_options(
6019 dispatch,
6020 &inputs,
6021 max_decisions,
6022 ))
6023}
6024
6025fn decision_surface_audit_args(max_decisions: usize) -> AuditDispatchArgs {
6026 AuditDispatchArgs {
6027 production_dead_code: false,
6028 production_health: false,
6029 production_dupes: false,
6030 dead_code_baseline: None,
6031 health_baseline: None,
6032 dupes_baseline: None,
6033 max_crap: None,
6034 coverage: None,
6035 coverage_root: None,
6036 no_css: true,
6037 css_deep: false,
6038 no_css_deep: false,
6039 gate: None,
6040 runtime_coverage: None,
6041 min_invocations_hot: 0,
6042 gate_marker: None,
6043 brief: true,
6044 max_decisions,
6045 walkthrough_guide: false,
6046 walkthrough_file: None,
6047 walkthrough: false,
6048 mark_viewed: Vec::new(),
6049 show_cleared: false,
6050 show_deprioritized: false,
6051 scope: None,
6052 }
6053}
6054
6055fn decision_surface_audit_options<'a>(
6056 dispatch: &'a DispatchContext<'a>,
6057 inputs: &'a ResolvedAuditInputs,
6058 max_decisions: usize,
6059) -> audit::AuditOptions<'a> {
6060 let cli = dispatch.cli;
6061 audit::AuditOptions {
6062 root: dispatch.root,
6063 config_path: &cli.config,
6064 cache_dir: &inputs.cache_dir,
6065 output: dispatch.output,
6066 json_style: dispatch.json_style,
6067 no_cache: cli.no_cache,
6068 threads: dispatch.threads,
6069 quiet: dispatch.quiet,
6070 allow_remote_extends: cli.allow_remote_extends,
6071 changed_since: cli.changed_since.as_deref(),
6072 production: cli.production,
6073 production_dead_code: Some(inputs.production.dead_code),
6074 production_health: Some(inputs.production.health),
6075 production_dupes: Some(inputs.production.dupes),
6076 workspace: cli.workspace.as_deref(),
6077 changed_workspaces: cli.changed_workspaces.as_deref(),
6078 explain: cli.explain,
6079 explain_skipped: cli.explain_skipped,
6080 performance: cli.performance,
6081 group_by: cli.group_by,
6082 dead_code_baseline: inputs.dead_code_baseline.as_deref(),
6083 health_baseline: inputs.health_baseline.as_deref(),
6084 dupes_baseline: inputs.dupes_baseline.as_deref(),
6085 health_baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
6086 fail_on_stale_baseline: false,
6090 max_crap: None,
6091 coverage: None,
6092 coverage_root: None,
6093 gate: inputs.audit_cfg.gate,
6094 include_entry_exports: cli.include_entry_exports,
6095 css: false,
6097 css_deep: false,
6098 runtime_coverage: None,
6099 min_invocations_hot: 0,
6100 brief: true,
6101 max_decisions,
6102 walkthrough_guide: false,
6103 walkthrough: false,
6104 mark_viewed: &[],
6105 show_cleared: false,
6106 walkthrough_file: None,
6107 show_deprioritized: false,
6108 scope: None,
6109 }
6110}
6111
6112struct HealthDispatchArgs<'a> {
6113 max_cyclomatic: Option<u16>,
6114 max_cognitive: Option<u16>,
6115 max_crap: Option<f64>,
6116 top: Option<usize>,
6117 sort: health::SortBy,
6118 complexity: bool,
6119 complexity_breakdown: bool,
6120 file_scores: bool,
6121 coverage_gaps: bool,
6122 hotspots: bool,
6123 ownership: bool,
6124 ownership_emails: Option<fallow_config::EmailMode>,
6125 targets: bool,
6126 type_coupling: bool,
6127 css: bool,
6128 effort: Option<EffortFilter>,
6129 score: bool,
6130 min_score: Option<f64>,
6131 min_severity: Option<fallow_output::FindingSeverity>,
6132 report_only: bool,
6133 fail_on_stale_baseline: bool,
6134 since: Option<&'a str>,
6135 min_commits: Option<u32>,
6136 save_snapshot: Option<&'a Option<String>>,
6137 trend: bool,
6138 coverage: Option<&'a std::path::Path>,
6139 coverage_root: Option<&'a std::path::Path>,
6140 runtime_coverage: Option<&'a std::path::Path>,
6141 min_invocations_hot: u64,
6142 min_observation_volume: Option<u32>,
6143 low_traffic_threshold: Option<f64>,
6144 scope: Option<std::path::PathBuf>,
6145}
6146
6147type ResolvedHealthCoverageInputs = fallow_api::CoverageInputs;
6148
6149fn resolve_coverage_inputs(
6160 cli_coverage: Option<&std::path::Path>,
6161 cli_coverage_root: Option<&std::path::Path>,
6162 output: fallow_config::OutputFormat,
6163 config_health: impl FnOnce() -> Result<fallow_config::HealthConfig, ExitCode>,
6164) -> Result<ResolvedHealthCoverageInputs, ExitCode> {
6165 let explicit = fallow_api::CoverageInputs {
6166 coverage: cli_coverage.map(std::path::Path::to_path_buf),
6167 coverage_root: cli_coverage_root.map(std::path::Path::to_path_buf),
6168 };
6169 let env = fallow_api::CoverageInputs {
6170 coverage: path_from_env("FALLOW_COVERAGE"),
6171 coverage_root: path_from_env("FALLOW_COVERAGE_ROOT"),
6172 };
6173 let config_health = if fallow_api::CoverageInputs::needs_config_layer(&explicit, &env) {
6174 Some(config_health()?)
6175 } else {
6176 None
6177 };
6178
6179 fallow_api::resolve_coverage_inputs(explicit, env, config_health.as_ref())
6180 .map_err(|err| emit_error(&err.to_string(), 2, output))
6181}
6182
6183fn resolve_health_coverage_inputs(
6186 dispatch: &DispatchContext<'_>,
6187 cli_coverage: Option<&std::path::Path>,
6188 cli_coverage_root: Option<&std::path::Path>,
6189) -> Result<ResolvedHealthCoverageInputs, ExitCode> {
6190 resolve_coverage_inputs(cli_coverage, cli_coverage_root, dispatch.output, || {
6191 Ok(load_config(
6192 dispatch.root,
6193 &dispatch.cli.config,
6194 LoadConfigArgs {
6195 output: dispatch.output,
6196 no_cache: dispatch.cli.no_cache,
6197 threads: dispatch.threads,
6198 production: dispatch.cli.production,
6199 quiet: dispatch.quiet,
6200 allow_remote_extends: dispatch.cli.allow_remote_extends,
6201 },
6202 )?
6203 .health)
6204 })
6205}
6206
6207fn path_from_env(name: &str) -> Option<PathBuf> {
6208 std::env::var_os(name)
6209 .filter(|value| !value.is_empty())
6210 .map(PathBuf::from)
6211}
6212
6213fn validate_health_report_only_gate(
6214 report_only: bool,
6215 min_score: Option<f64>,
6216 min_severity: Option<fallow_output::FindingSeverity>,
6217 output: fallow_config::OutputFormat,
6218) -> Result<(), ExitCode> {
6219 if report_only && (min_score.is_some() || min_severity.is_some()) {
6220 return Err(emit_error(
6221 "--report-only cannot be combined with --min-score or --min-severity. \
6222 --report-only always exits 0; drop it to gate on score/severity, or \
6223 drop the gate flags to stay advisory.",
6224 2,
6225 output,
6226 ));
6227 }
6228
6229 Ok(())
6230}
6231
6232fn resolve_runtime_coverage_options(
6233 runtime_coverage: Option<&std::path::Path>,
6234 min_invocations_hot: u64,
6235 min_observation_volume: Option<u32>,
6236 low_traffic_threshold: Option<f64>,
6237 output: fallow_config::OutputFormat,
6238) -> Result<Option<fallow_engine::health::RuntimeCoverageOptions>, ExitCode> {
6239 let Some(path) = runtime_coverage else {
6240 return Ok(None);
6241 };
6242
6243 health::coverage::prepare_options(
6244 path,
6245 min_invocations_hot,
6246 min_observation_volume,
6247 low_traffic_threshold,
6248 output,
6249 )
6250 .map(Some)
6251}
6252
6253fn dispatch_health(dispatch: &DispatchContext<'_>, args: &HealthDispatchArgs<'_>) -> ExitCode {
6254 let cli = dispatch.cli;
6255 let root = dispatch.root;
6256 let (output, _quiet, _fail_on_issues) =
6257 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
6258 if let Err(code) = validate_health_report_only_gate(
6259 args.report_only,
6260 args.min_score,
6261 args.min_severity,
6262 output,
6263 ) {
6264 return code;
6265 }
6266 let runtime_coverage = match resolve_runtime_coverage_options(
6267 args.runtime_coverage,
6268 args.min_invocations_hot,
6269 args.min_observation_volume,
6270 args.low_traffic_threshold,
6271 output,
6272 ) {
6273 Ok(options) => options,
6274 Err(code) => return code,
6275 };
6276 let production = match resolve_production_modes(cli, root, output, false, false, false) {
6277 Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::Health),
6278 Err(code) => return code,
6279 };
6280 let coverage_inputs =
6281 match resolve_health_coverage_inputs(dispatch, args.coverage, args.coverage_root) {
6282 Ok(inputs) => inputs,
6283 Err(code) => return code,
6284 };
6285 let run = derive_health_dispatch_run(args, output, &coverage_inputs, runtime_coverage);
6286 run_health_dispatch(dispatch, args, ResolvedHealthDispatch { run, production })
6287}
6288
6289fn derive_health_dispatch_run<'a>(
6290 args: &'a HealthDispatchArgs<'a>,
6291 output: fallow_config::OutputFormat,
6292 coverage_inputs: &'a ResolvedHealthCoverageInputs,
6293 runtime_coverage: Option<fallow_engine::health::RuntimeCoverageOptions>,
6294) -> fallow_engine::health::HealthRunOptions<'a> {
6295 let mut run = fallow_engine::health::derive_health_run_options(
6296 fallow_engine::health::HealthRunOptionsInput {
6297 output,
6298 thresholds: health_threshold_overrides(args),
6299 top: args.top,
6300 sort: args.sort.clone().into(),
6301 complexity: args.complexity,
6302 file_scores: args.file_scores,
6303 coverage_gaps: args.coverage_gaps,
6304 hotspots: args.hotspots,
6305 ownership: args.ownership,
6306 ownership_emails: args.ownership_emails,
6307 targets: args.targets,
6308 css: args.css,
6309 effort: args.effort.map(EffortFilter::to_estimate),
6310 score: args.score,
6311 gates: health_gate_options(args),
6312 snapshot_requested: args.save_snapshot.is_some(),
6313 trend: args.trend,
6314 since: args.since,
6315 min_commits: args.min_commits,
6316 coverage_inputs: health_coverage_inputs(coverage_inputs),
6317 runtime_coverage,
6318 },
6319 );
6320 if args.type_coupling && !run.sections.any_section {
6321 run.sections = fallow_engine::health::DerivedHealthSections {
6322 any_section: true,
6323 complexity: false,
6324 file_scores: false,
6325 coverage_gaps: false,
6326 hotspots: false,
6327 targets: false,
6328 css: false,
6329 score: false,
6330 force_full: false,
6331 score_only_output: false,
6332 };
6333 }
6334 run
6335}
6336
6337fn health_threshold_overrides(
6338 args: &HealthDispatchArgs<'_>,
6339) -> fallow_engine::health::HealthThresholdOverrides {
6340 fallow_engine::health::HealthThresholdOverrides {
6341 max_cyclomatic: args.max_cyclomatic,
6342 max_cognitive: args.max_cognitive,
6343 max_crap: args.max_crap,
6344 }
6345}
6346
6347fn health_gate_options(args: &HealthDispatchArgs<'_>) -> fallow_engine::health::HealthGateOptions {
6348 fallow_engine::health::HealthGateOptions {
6349 min_score: args.min_score,
6350 min_severity: args.min_severity,
6351 report_only: args.report_only,
6352 fail_on_stale_baseline: args.fail_on_stale_baseline,
6353 }
6354}
6355
6356fn health_coverage_inputs(
6357 coverage_inputs: &ResolvedHealthCoverageInputs,
6358) -> fallow_engine::health::HealthCoverageInputs<'_> {
6359 fallow_engine::health::HealthCoverageInputs {
6360 coverage: coverage_inputs.coverage.as_deref(),
6361 coverage_root: coverage_inputs.coverage_root.as_deref(),
6362 coverage_relocated: false,
6363 }
6364}
6365
6366struct ResolvedHealthDispatch<'a> {
6370 run: fallow_engine::health::HealthRunOptions<'a>,
6371 production: bool,
6372}
6373
6374fn run_health_dispatch(
6377 dispatch: &DispatchContext<'_>,
6378 args: &HealthDispatchArgs<'_>,
6379 resolved: ResolvedHealthDispatch<'_>,
6380) -> ExitCode {
6381 let cli = dispatch.cli;
6382 let (output, quiet, _fail_on_issues) =
6383 (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
6384 let run = resolved.run;
6385 let sections = run.sections;
6386 let production = resolved.production;
6387 health::run_health(
6388 &HealthOptions {
6389 root: dispatch.root,
6390 config_path: &cli.config,
6391 output,
6392 no_cache: cli.no_cache,
6393 threads: dispatch.threads,
6394 quiet,
6395 thresholds: run.thresholds,
6396 top: run.top,
6397 sort: run.sort,
6398 production,
6399 production_override: Some(production),
6400 allow_remote_extends: cli.allow_remote_extends,
6401 changed_since: cli.changed_since.as_deref(),
6402 diff_index: None,
6403 use_shared_diff_index: true,
6404 workspace: cli.workspace.as_deref(),
6405 changed_workspaces: cli.changed_workspaces.as_deref(),
6406 baseline: cli.baseline.as_deref(),
6407 save_baseline: cli.save_baseline.as_deref(),
6408 baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
6409 baseline_mode_explicit: cli.baseline_mode.is_some(),
6410 complexity: sections.complexity,
6411 file_scores: sections.file_scores,
6412 coverage_gaps: sections.coverage_gaps,
6413 config_activates_coverage_gaps: !sections.any_section,
6414 hotspots: sections.hotspots,
6415 ownership: run.ownership,
6416 ownership_emails: run.ownership_emails,
6417 targets: sections.targets,
6418 css: sections.css,
6419 css_deep: false,
6420 force_full: sections.force_full,
6421 score_only_output: sections.score_only_output,
6422 enforce_coverage_gap_gate: true,
6423 effort: run.effort,
6424 score: sections.score,
6425 gates: run.gates,
6426 since: run.since,
6427 min_commits: run.min_commits,
6428 explain: cli.explain,
6429 summary: cli.summary,
6430 save_snapshot: args
6431 .save_snapshot
6432 .map(|opt| PathBuf::from(opt.as_deref().unwrap_or_default())),
6433 trend: args.trend,
6434 coverage_inputs: run.coverage_inputs,
6435 performance: cli.performance,
6436 runtime_coverage: run.runtime_coverage,
6437 churn_file: cli.churn_file.as_deref(),
6438 analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
6439 complexity_breakdown: args.complexity_breakdown,
6440 group_by: cli.group_by.map(Into::into),
6441 scope: args.scope.clone(),
6442 },
6443 dispatch.json_style,
6444 &health::TypeAwareHealthOptions {
6445 enabled: cli.type_aware_override(),
6446 requested: args.type_coupling,
6447 unfiltered: health_type_coupling_is_default_section(args),
6448 projects: &cli.type_aware_project,
6449 require: cli.type_aware_require.map(Into::into),
6450 },
6451 )
6452}
6453
6454fn health_type_coupling_is_default_section(args: &HealthDispatchArgs<'_>) -> bool {
6455 !args.complexity
6456 && !args.file_scores
6457 && !args.coverage_gaps
6458 && !args.hotspots
6459 && !args.ownership
6460 && !args.targets
6461 && !args.css
6462 && !args.score
6463 && args.min_score.is_none()
6464 && args.min_severity.is_none()
6465 && args.runtime_coverage.is_none()
6466}
6467
6468#[cfg(test)]
6469mod tests {
6470 use super::*;
6471
6472 #[test]
6476 fn cli_definition_has_no_flag_collisions() {
6477 use clap::CommandFactory;
6478 Cli::command().debug_assert();
6479 }
6480
6481 #[test]
6482 fn impact_statusline_subcommand_parses() {
6483 use clap::Parser;
6484
6485 let cli = Cli::try_parse_from(["fallow", "impact", "statusline"]).expect("argv parses");
6486 assert!(matches!(
6487 cli.command,
6488 Some(Command::Impact {
6489 subcommand: Some(ImpactCli::Statusline),
6490 ..
6491 })
6492 ));
6493 }
6494
6495 #[test]
6496 fn impact_statusline_bypasses_command_epilogue() {
6497 use clap::Parser;
6498
6499 let statusline =
6500 Cli::try_parse_from(["fallow", "impact", "statusline"]).expect("argv parses");
6501 assert!(is_impact_statusline(&statusline));
6502
6503 let status = Cli::try_parse_from(["fallow", "impact", "status"]).expect("argv parses");
6504 assert!(!is_impact_statusline(&status));
6505
6506 let all_statusline =
6507 Cli::try_parse_from(["fallow", "impact", "--all", "statusline"]).expect("argv parses");
6508 assert!(!is_impact_statusline(&all_statusline));
6509 }
6510
6511 #[test]
6512 fn regression_baseline_help_explains_the_default_destination() {
6513 use clap::CommandFactory;
6514 let help = Cli::command().render_long_help().to_string();
6515
6516 assert!(help.contains("Omit PATH to update regression.baseline"));
6517 assert!(help.contains("discovered fallow config"));
6518 assert!(help.contains("create .fallowrc.json when none exists"));
6519 }
6520
6521 #[test]
6525 fn after_help_lists_every_task_matrix_command() {
6526 for row in crate::task_matrix::TASK_MATRIX {
6527 assert!(
6528 TOP_LEVEL_AFTER_LONG_HELP.contains(row.command),
6529 "root --help cheat sheet is missing task-matrix command '{}'; \
6530 update the top_level_task_cheat_sheet! fragment to match TASK_MATRIX",
6531 row.command
6532 );
6533 }
6534 }
6535
6536 #[test]
6543 fn after_help_lists_every_visible_subcommand() {
6544 use clap::CommandFactory;
6545
6546 for sub in Cli::command().get_subcommands() {
6547 if sub.is_hide_set() {
6548 continue;
6549 }
6550 let name = sub.get_name();
6551 let listed = TOP_LEVEL_AFTER_LONG_HELP
6552 .lines()
6553 .any(|line| line.split_whitespace().next() == Some(name));
6554 assert!(
6555 listed,
6556 "root --help command list is missing subcommand '{name}'; \
6557 add it to a top_level_*_command_groups! section"
6558 );
6559 }
6560 }
6561
6562 #[test]
6566 fn short_help_stays_scannable_with_cheat_sheet_and_pointer() {
6567 use clap::CommandFactory;
6568
6569 let help = Cli::command().render_help().to_string();
6570 let lines = help.lines().count();
6571 assert!(
6572 lines < 90,
6573 "root -h grew to {lines} lines; keep the short surface under 90 \
6574 (curate hide_short_help and the short after-help instead)"
6575 );
6576 assert!(help.contains("When the agent is about to..."));
6577 assert!(help.contains("Run fallow --help for the complete command list."));
6578 }
6579
6580 #[test]
6584 fn high_value_commands_route_to_distinct_workflows() {
6585 use clap::Parser;
6586 use fallow_config::OutputFormat;
6587
6588 let distinct = [
6589 (vec!["fallow", "impact"], telemetry::Workflow::Impact),
6590 (vec!["fallow", "security"], telemetry::Workflow::Security),
6591 (vec!["fallow", "fix"], telemetry::Workflow::Fix),
6592 (
6593 vec!["fallow", "explain", "unused-exports"],
6594 telemetry::Workflow::Explain,
6595 ),
6596 (
6597 vec!["fallow", "watch"],
6598 telemetry::Workflow::CodeQualityReview,
6599 ),
6600 (
6601 vec!["fallow", "list"],
6602 telemetry::Workflow::ProjectInventory,
6603 ),
6604 (
6605 vec!["fallow", "workspaces"],
6606 telemetry::Workflow::ProjectInventory,
6607 ),
6608 (
6609 vec!["fallow", "schema"],
6610 telemetry::Workflow::ProjectInventory,
6611 ),
6612 (vec!["fallow", "init"], telemetry::Workflow::Setup),
6613 (
6614 vec!["fallow", "hooks", "install", "--target", "git"],
6615 telemetry::Workflow::Setup,
6616 ),
6617 (vec!["fallow", "config-schema"], telemetry::Workflow::Setup),
6618 (vec!["fallow", "plugin-schema"], telemetry::Workflow::Setup),
6619 (
6620 vec!["fallow", "rule-pack-schema"],
6621 telemetry::Workflow::Setup,
6622 ),
6623 (vec!["fallow", "config"], telemetry::Workflow::Setup),
6624 (
6625 vec!["fallow", "ci-template", "gitlab"],
6626 telemetry::Workflow::Setup,
6627 ),
6628 (vec!["fallow", "migrate"], telemetry::Workflow::Setup),
6629 (
6630 vec!["fallow", "telemetry", "status"],
6631 telemetry::Workflow::Setup,
6632 ),
6633 (vec!["fallow", "setup-hooks"], telemetry::Workflow::Setup),
6634 (
6635 vec!["fallow", "audit-cache", "remove", "--root", "."],
6636 telemetry::Workflow::Setup,
6637 ),
6638 (
6639 vec!["fallow", "license", "status"],
6640 telemetry::Workflow::License,
6641 ),
6642 ];
6643 for (argv, expected) in distinct {
6644 let cli = Cli::try_parse_from(&argv).expect("argv parses");
6645 assert_eq!(
6646 telemetry_workflow_for_command(cli.command.as_ref(), OutputFormat::Json),
6647 expected,
6648 "{argv:?} should map to {expected:?}"
6649 );
6650 }
6651 }
6652
6653 #[test]
6658 fn version_flag_accepts_lower_v_upper_v_and_long() {
6659 use clap::CommandFactory;
6660 for argv in [["fallow", "-v"], ["fallow", "-V"], ["fallow", "--version"]] {
6661 let err = Cli::command()
6662 .try_get_matches_from(argv)
6663 .expect_err("version flag should short-circuit parsing");
6664 assert_eq!(
6665 err.kind(),
6666 clap::error::ErrorKind::DisplayVersion,
6667 "{argv:?} should trigger the Version action"
6668 );
6669 }
6670 }
6671
6672 #[test]
6677 fn cli_help_text_contains_no_implementation_status_wording() {
6678 use clap::CommandFactory;
6679 let mut root = Cli::command();
6680 let mut violations: Vec<(String, String)> = Vec::new();
6681 visit_help(&mut root, "fallow", &mut violations);
6682 assert!(
6683 violations.is_empty(),
6684 "found implementation-status wording in --help output:\n{}",
6685 violations
6686 .iter()
6687 .map(|(cmd, line)| format!(" {cmd}: {line}"))
6688 .collect::<Vec<_>>()
6689 .join("\n")
6690 );
6691 }
6692
6693 #[test]
6694 fn dependency_override_help_is_package_manager_neutral() {
6695 use clap::CommandFactory;
6696 let help = Cli::command()
6697 .find_subcommand_mut("dead-code")
6698 .expect("dead-code command")
6699 .render_long_help()
6700 .to_string();
6701
6702 assert!(help.contains("Only report unused package-manager dependency overrides"));
6703 assert!(help.contains("Only report misconfigured package-manager dependency overrides"));
6704 assert!(!help.contains("unused pnpm dependency overrides"));
6705 assert!(!help.contains("misconfigured pnpm dependency overrides"));
6706 }
6707
6708 #[test]
6709 fn top_level_help_groups_commands_by_workflow() {
6710 use clap::CommandFactory;
6711 let help = Cli::command().render_long_help().to_string();
6712 let expected_order = [
6713 "Analysis:",
6714 " dead-code",
6715 " dupes",
6716 " health",
6717 " flags",
6718 " security",
6719 " audit",
6720 "Workflow:",
6721 " watch",
6722 " fix",
6723 "Project inspection:",
6724 " list",
6725 " workspaces",
6726 " explain",
6727 " impact",
6728 " viz",
6729 "Setup and configuration:",
6730 " init",
6731 " recommend",
6732 " migrate",
6733 " config",
6734 " config-schema",
6735 " plugin-schema",
6736 " plugin-check",
6737 " rule-pack-schema",
6738 "Automation and CI:",
6739 " ci",
6740 " ci-template",
6741 " hooks",
6742 " setup-hooks",
6743 "Runtime coverage:",
6744 " coverage",
6745 " license",
6746 "Reference:",
6747 " schema",
6748 " help",
6749 "Options:",
6750 ];
6751 let mut cursor = 0;
6752 for needle in expected_order {
6753 let Some(offset) = help[cursor..].find(needle) else {
6754 panic!("top-level help missing `{needle}` after byte {cursor}:\n{help}");
6755 };
6756 cursor += offset + needle.len();
6757 }
6758 }
6759
6760 #[test]
6761 fn security_help_hides_globals_rejected_by_security_validator() {
6762 let help = render_security_help(SecurityHelpTarget::Parent);
6763
6764 for long in SECURITY_UNSUPPORTED_GLOBAL_LONGS {
6765 assert!(
6766 !help_contains_long_flag(&help, long),
6767 "security help must hide unsupported --{long}:\n{help}"
6768 );
6769 }
6770
6771 for long in [
6772 "root",
6773 "config",
6774 "format",
6775 "quiet",
6776 "no-cache",
6777 "threads",
6778 "changed-since",
6779 "diff-file",
6780 "diff-stdin",
6781 "workspace",
6782 "changed-workspaces",
6783 "ci",
6784 "fail-on-issues",
6785 "sarif-file",
6786 "summary",
6787 "output-file",
6788 "max-file-size",
6789 "explain",
6790 "surface",
6791 ] {
6792 assert!(
6793 help_contains_long_flag(&help, long),
6794 "security help must keep supported --{long}:\n{help}"
6795 );
6796 }
6797 }
6798
6799 #[test]
6800 fn security_help_detection_covers_subcommand_and_help_alias_forms() {
6801 assert_eq!(
6802 security_help_target(["security", "--help"]),
6803 Some(SecurityHelpTarget::Parent)
6804 );
6805 assert_eq!(
6806 security_help_target(["security", "-h"]),
6807 Some(SecurityHelpTarget::Parent)
6808 );
6809 assert_eq!(
6810 security_help_target(["--format", "json", "security", "--help"]),
6811 Some(SecurityHelpTarget::Parent)
6812 );
6813 assert_eq!(
6814 security_help_target(["help", "security"]),
6815 Some(SecurityHelpTarget::Parent)
6816 );
6817 assert_eq!(
6818 security_help_target(["security", "survivors", "--help"]),
6819 Some(SecurityHelpTarget::Survivors)
6820 );
6821 assert_eq!(
6822 security_help_target(["security", "survivors", "-h"]),
6823 Some(SecurityHelpTarget::Survivors)
6824 );
6825 assert_eq!(
6826 security_help_target(["help", "security", "survivors"]),
6827 Some(SecurityHelpTarget::Survivors)
6828 );
6829 assert_eq!(
6830 security_help_target(["security", "blind-spots", "--help"]),
6831 Some(SecurityHelpTarget::BlindSpots)
6832 );
6833 assert_eq!(
6834 security_help_target(["help", "security", "blind-spots"]),
6835 Some(SecurityHelpTarget::BlindSpots)
6836 );
6837 assert_eq!(security_help_target(["health", "--help"]), None);
6838 assert_eq!(security_help_target(["help", "health"]), None);
6839 }
6840
6841 #[test]
6842 fn security_unsupported_global_validator_matches_hidden_help_contract() {
6843 for (argv, expected) in [
6844 (vec!["fallow", "security", "--performance"], "--performance"),
6845 (
6846 vec!["fallow", "security", "--baseline", "base.json"],
6847 "--baseline",
6848 ),
6849 (
6850 vec!["fallow", "security", "--fail-on-stale-baseline"],
6851 "--fail-on-stale-baseline",
6852 ),
6853 (
6854 vec!["fallow", "security", "--dupes-mode", "weak"],
6855 "--dupes-mode",
6856 ),
6857 ] {
6858 let cli = Cli::try_parse_from(argv).expect("security global parses before validation");
6859 assert_eq!(unsupported_security_global(&cli), Some(expected));
6860 }
6861
6862 let explain = Cli::try_parse_from(["fallow", "security", "--explain"])
6863 .expect("security --explain parses");
6864 assert_eq!(unsupported_security_global(&explain), None);
6865 }
6866
6867 #[test]
6868 fn programmatic_common_options_track_analysis_affecting_cli_globals() {
6869 use clap::CommandFactory;
6870
6871 let cli_flags: std::collections::BTreeSet<String> = Cli::command()
6872 .get_arguments()
6873 .filter(|arg| arg.is_global_set())
6874 .filter_map(|arg| arg.get_long().map(str::to_owned))
6875 .filter(|name| {
6876 matches!(
6877 name.as_str(),
6878 "root"
6879 | "config"
6880 | "allow-remote-extends"
6881 | "no-cache"
6882 | "threads"
6883 | "changed-since"
6884 | "diff-file"
6885 | "production"
6886 | "workspace"
6887 | "changed-workspaces"
6888 | "explain"
6889 )
6890 })
6891 .collect();
6892 let programmatic_flags: std::collections::BTreeSet<String> =
6893 fallow_api::COMMON_ANALYSIS_OPTION_FLAGS
6894 .iter()
6895 .map(|flag| (*flag).to_owned())
6896 .collect();
6897
6898 assert_eq!(programmatic_flags, cli_flags);
6899 }
6900
6901 #[test]
6902 fn dead_code_registry_filter_flags_are_exposed_by_clap() {
6903 use clap::CommandFactory;
6904
6905 let cli = Cli::command();
6906 let dead_code = cli
6907 .get_subcommands()
6908 .find(|command| command.get_name() == "dead-code")
6909 .expect("dead-code subcommand is registered");
6910 let cli_flags: std::collections::BTreeSet<String> = dead_code
6911 .get_arguments()
6912 .filter_map(|arg| arg.get_long().map(|long| format!("--{long}")))
6913 .collect();
6914
6915 for flag in fallow_types::issue_meta::DEAD_CODE_FILTER_FLAGS.iter() {
6916 assert!(
6917 cli_flags.contains(*flag),
6918 "registry filter flag {flag} is missing from dead-code clap args"
6919 );
6920 }
6921 }
6922
6923 fn help_contains_long_flag(help: &str, long: &str) -> bool {
6924 let flag = format!("--{long}");
6925 help.split(|c: char| c.is_whitespace() || c == ',' || c == '[' || c == ']')
6926 .any(|token| token == flag)
6927 }
6928
6929 fn visit_help(cmd: &mut clap::Command, path: &str, violations: &mut Vec<(String, String)>) {
6930 let help = cmd.render_long_help().to_string();
6931 for line in scan_forbidden(&help) {
6932 violations.push((path.to_owned(), line));
6933 }
6934 let names: Vec<String> = cmd
6935 .get_subcommands()
6936 .map(|sub| sub.get_name().to_owned())
6937 .collect();
6938 for name in names {
6939 if name == "help" {
6940 continue;
6941 }
6942 if let Some(sub) = cmd.find_subcommand_mut(&name) {
6943 let sub_path = format!("{path} {name}");
6944 visit_help(sub, &sub_path, violations);
6945 }
6946 }
6947 }
6948
6949 fn scan_forbidden(s: &str) -> Vec<String> {
6950 let lower = s.to_ascii_lowercase();
6951 let mut out = Vec::new();
6952 for word in ["stub", "placeholder"] {
6953 if let Some(idx) = find_whole_word(&lower, word) {
6954 out.push(extract_line(s, idx));
6955 }
6956 }
6957 if let Some(idx) = lower.find("not yet") {
6958 out.push(extract_line(s, idx));
6959 }
6960 out
6961 }
6962
6963 fn find_whole_word(haystack: &str, word: &str) -> Option<usize> {
6964 let bytes = haystack.as_bytes();
6965 let mut start = 0;
6966 while let Some(rel) = haystack[start..].find(word) {
6967 let abs = start + rel;
6968 let before_ok = abs == 0 || !bytes[abs - 1].is_ascii_alphanumeric();
6969 let after_idx = abs + word.len();
6970 let after_ok = after_idx >= bytes.len() || !bytes[after_idx].is_ascii_alphanumeric();
6971 if before_ok && after_ok {
6972 return Some(abs);
6973 }
6974 start = abs + word.len();
6975 }
6976 None
6977 }
6978
6979 fn extract_line(s: &str, byte_idx: usize) -> String {
6980 let line_start = s[..byte_idx].rfind('\n').map_or(0, |i| i + 1);
6981 let line_end = s[byte_idx..].find('\n').map_or(s.len(), |i| byte_idx + i);
6982 s[line_start..line_end].trim().to_owned()
6983 }
6984
6985 #[test]
6986 fn emit_error_returns_given_exit_code() {
6987 let code = emit_error("test error", 2, fallow_config::OutputFormat::Human);
6988 assert_eq!(code, ExitCode::from(2));
6989 }
6990
6991 fn telemetry_run_for_mode(mode: telemetry::AnalysisMode) -> TelemetryRun {
6992 TelemetryRun {
6993 workflow: telemetry::Workflow::Health,
6994 output: fallow_config::OutputFormat::Json,
6995 quiet: true,
6996 start: std::time::Instant::now(),
6997 context: telemetry::WorkflowContext {
6998 run_scope: telemetry::RunScope::FullProject,
6999 config_shape: telemetry::ConfigShape::Default,
7000 output_destination: telemetry::OutputDestination::Stdout,
7001 analysis_mode: mode,
7002 },
7003 }
7004 }
7005
7006 #[test]
7007 fn fallback_failure_reason_skips_success_and_findings() {
7008 let run = telemetry_run_for_mode(telemetry::AnalysisMode::Static);
7009
7010 assert_eq!(fallback_failure_reason_for(&run, ExitCode::SUCCESS), None);
7011 assert_eq!(fallback_failure_reason_for(&run, ExitCode::from(1)), None);
7012 }
7013
7014 #[test]
7015 fn fallback_failure_reason_classifies_network_auth_and_analysis() {
7016 let static_run = telemetry_run_for_mode(telemetry::AnalysisMode::Static);
7017 let cloud_run = telemetry_run_for_mode(telemetry::AnalysisMode::ProductionCoverage);
7018
7019 assert_eq!(
7020 fallback_failure_reason_for(&static_run, ExitCode::from(api::NETWORK_EXIT_CODE)),
7021 Some(telemetry::FailureReason::Network),
7022 );
7023 assert_eq!(
7024 fallback_failure_reason_for(&static_run, ExitCode::from(12)),
7025 Some(telemetry::FailureReason::Auth),
7026 );
7027 assert_eq!(
7028 fallback_failure_reason_for(&cloud_run, ExitCode::from(3)),
7029 Some(telemetry::FailureReason::Auth),
7030 );
7031 assert_eq!(
7032 fallback_failure_reason_for(&static_run, ExitCode::from(2)),
7033 Some(telemetry::FailureReason::Analysis),
7034 );
7035 }
7036
7037 #[test]
7038 fn bare_coverage_flags_parse_without_subcommand() {
7039 let cli = Cli::try_parse_from([
7040 "fallow",
7041 "--coverage",
7042 "coverage/coverage-final.json",
7043 "--coverage-root",
7044 "/ci/workspace",
7045 ])
7046 .expect("bare combined coverage flags should parse");
7047 assert!(cli.command.is_none());
7048 assert_eq!(
7049 cli.coverage.as_deref(),
7050 Some(std::path::Path::new("coverage/coverage-final.json"))
7051 );
7052 assert_eq!(
7053 cli.coverage_root.as_deref(),
7054 Some(std::path::Path::new("/ci/workspace"))
7055 );
7056 }
7057
7058 #[test]
7059 fn bare_coverage_before_subcommand_is_detectable() {
7060 let cli = Cli::try_parse_from([
7061 "fallow",
7062 "--coverage",
7063 "coverage/coverage-final.json",
7064 "dead-code",
7065 ])
7066 .expect("clap should parse pre-subcommand bare coverage for custom rejection");
7067 assert!(cli.command.is_some());
7068 assert!(cli_has_bare_coverage_input(&cli));
7069 let message = bare_coverage_subcommand_error_message();
7070 assert!(message.contains("bare combined-mode flags"));
7071 assert!(message.contains("fallow health --coverage <coverage-final.json>"));
7072 }
7073
7074 #[test]
7075 fn subcommand_coverage_flag_keeps_regular_clap_error() {
7076 let Err(err) = Cli::try_parse_from(["fallow", "dead-code", "--coverage"]) else {
7077 panic!("dead-code --coverage should fail to parse");
7078 };
7079 assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
7080 }
7081
7082 #[test]
7083 fn type_aware_flags_parse_for_semantic_analysis() {
7084 let cli = Cli::try_parse_from([
7085 "fallow",
7086 "dead-code",
7087 "--unused-class-members",
7088 "--type-aware",
7089 "--type-aware-project",
7090 "tsconfig.json",
7091 "--type-aware-project",
7092 "packages/web/tsconfig.json",
7093 ])
7094 .expect("type-aware flag should parse");
7095 assert!(cli.type_aware);
7096 assert_eq!(
7097 cli.type_aware_project,
7098 [
7099 PathBuf::from("tsconfig.json"),
7100 PathBuf::from("packages/web/tsconfig.json")
7101 ]
7102 );
7103 let Some(Command::Check {
7104 unused_class_members,
7105 ..
7106 }) = cli.command
7107 else {
7108 panic!("dead-code should parse as the check command");
7109 };
7110 assert!(unused_class_members);
7111 }
7112
7113 #[test]
7114 fn no_type_aware_conflicts_with_type_aware() {
7115 let Err(err) = Cli::try_parse_from(["fallow", "audit", "--type-aware", "--no-type-aware"])
7116 else {
7117 panic!("--no-type-aware must conflict with --type-aware");
7118 };
7119 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
7120 }
7121
7122 #[test]
7123 fn no_type_aware_forces_semantic_analysis_off() {
7124 let cli = Cli::try_parse_from(["fallow", "audit", "--no-type-aware"])
7125 .expect("--no-type-aware should parse on audit");
7126 assert_eq!(cli.type_aware_override(), Some(false));
7127
7128 let cli = Cli::try_parse_from(["fallow", "dead-code", "--type-aware"])
7129 .expect("--type-aware should parse");
7130 assert_eq!(cli.type_aware_override(), Some(true));
7131
7132 let cli = Cli::try_parse_from(["fallow", "dead-code"]).expect("bare command should parse");
7133 assert_eq!(cli.type_aware_override(), None);
7134 }
7135
7136 #[test]
7137 fn type_aware_status_output_hides_host_paths() {
7138 let root = Path::new("/private/work/project");
7139 let output = type_aware_status_output(
7140 root,
7141 fallow_api::TypeAwareStatus {
7142 available: false,
7143 discovery_source: Some("environment-override"),
7144 companion_path: Some(PathBuf::from("/private/tools/fallow-type-aware")),
7145 package_version: None,
7146 protocol_version: 7,
7147 backend_family: None,
7148 backend_version: None,
7149 remediation: Some(
7150 "failed to launch /private/tools/fallow-type-aware from /private/work/project"
7151 .to_string(),
7152 ),
7153 },
7154 );
7155
7156 assert_eq!(
7157 output.schema_version.0,
7158 fallow_output::TYPE_AWARE_STATUS_SCHEMA_VERSION
7159 );
7160 assert_eq!(output.companion_path.as_deref(), Some("fallow-type-aware"));
7161 let remediation = output.remediation.expect("remediation");
7162 assert!(!remediation.contains("/private/"));
7163 assert!(remediation.contains("fallow-type-aware"));
7164 }
7165
7166 #[test]
7167 fn format_parsing_covers_all_variants() {
7168 assert!(matches!(parse_format_arg("json"), Some(Format::Json)));
7169 assert!(matches!(parse_format_arg("JSON"), Some(Format::Json)));
7170 assert!(matches!(parse_format_arg("human"), Some(Format::Human)));
7171 assert!(matches!(parse_format_arg("sarif"), Some(Format::Sarif)));
7172 assert!(matches!(parse_format_arg("compact"), Some(Format::Compact)));
7173 assert!(matches!(
7174 parse_format_arg("markdown"),
7175 Some(Format::Markdown)
7176 ));
7177 assert!(matches!(parse_format_arg("md"), Some(Format::Markdown)));
7178 assert!(matches!(
7179 parse_format_arg("codeclimate"),
7180 Some(Format::CodeClimate)
7181 ));
7182 assert!(matches!(
7183 parse_format_arg("gitlab-codequality"),
7184 Some(Format::CodeClimate)
7185 ));
7186 assert!(matches!(
7187 parse_format_arg("gitlab-code-quality"),
7188 Some(Format::CodeClimate)
7189 ));
7190 assert!(matches!(
7191 parse_format_arg("pr-comment-github"),
7192 Some(Format::PrCommentGithub)
7193 ));
7194 assert!(matches!(
7195 parse_format_arg("pr-comment-gitlab"),
7196 Some(Format::PrCommentGitlab)
7197 ));
7198 assert!(matches!(
7199 parse_format_arg("review-github"),
7200 Some(Format::ReviewGithub)
7201 ));
7202 assert!(matches!(
7203 parse_format_arg("review-gitlab"),
7204 Some(Format::ReviewGitlab)
7205 ));
7206 assert!(matches!(parse_format_arg("badge"), Some(Format::Badge)));
7207 assert!(parse_format_arg("xml").is_none());
7208 assert!(parse_format_arg("").is_none());
7209 }
7210
7211 #[test]
7212 fn quiet_parsing_logic() {
7213 let parse = |s: &str| -> bool { s == "1" || s.eq_ignore_ascii_case("true") };
7214 assert!(parse("1"));
7215 assert!(parse("true"));
7216 assert!(parse("TRUE"));
7217 assert!(parse("True"));
7218 assert!(!parse("0"));
7219 assert!(!parse("false"));
7220 assert!(!parse("yes"));
7221 }
7222
7223 #[test]
7224 fn tracing_filter_defaults_to_warn_without_env() {
7225 assert_eq!(build_tracing_filter(None).to_string(), "warn");
7226 }
7227
7228 #[test]
7229 fn tracing_filter_respects_explicit_env_directives() {
7230 assert_eq!(build_tracing_filter(Some("info")).to_string(), "info");
7231 }
7232
7233 #[test]
7234 fn tracing_filter_treats_empty_env_as_off() {
7235 assert_eq!(build_tracing_filter(Some("")).to_string(), "off");
7236 assert_eq!(build_tracing_filter(Some(" ")).to_string(), "off");
7237 }
7238}