Skip to main content

fallow_cli/
lib.rs

1#![expect(
2    clippy::print_stdout,
3    clippy::print_stderr,
4    reason = "CLI binary produces intentional terminal output"
5)]
6#![cfg_attr(
7    test,
8    allow(
9        clippy::unwrap_used,
10        clippy::expect_used,
11        reason = "tests use unwrap and expect to keep fixture setup concise"
12    )
13)]
14
15use std::io::IsTerminal as _;
16use std::path::{Path, PathBuf};
17use std::process::ExitCode;
18
19use clap::{Parser, Subcommand};
20
21mod api;
22#[cfg(test)]
23mod architecture_boundaries;
24mod audit;
25mod audit_brief;
26mod audit_decision_surface;
27mod audit_focus;
28mod audit_walkthrough;
29mod base_worktree;
30/// Re-exported for integration tests so they hash reusable-cache roots through
31/// the exact production path (`dunce` canonicalization + platform path-identity
32/// bytes) rather than an approximation that diverges on Windows.
33pub use base_worktree::canonical_root_hash;
34mod walkthrough_state;
35use fallow_engine::baseline;
36mod cache_notice;
37mod check;
38mod ci;
39mod ci_template;
40mod cli_format;
41mod cli_hooks;
42mod cli_impact;
43mod cli_production;
44mod cli_report;
45mod cli_startup;
46pub use fallow_engine::codeowners;
47mod combined;
48mod config;
49mod coverage;
50mod dupes;
51pub mod explain;
52mod fix;
53mod flags;
54mod guard;
55mod health;
56mod impact;
57mod init;
58mod inspect;
59mod json_style;
60mod license;
61mod list;
62mod migrate;
63mod onboarding;
64#[cfg(test)]
65mod output_envelope;
66mod output_runtime;
67mod path_util;
68mod plugin_check;
69mod rayon_pool;
70mod regression;
71pub mod report;
72mod rule_pack;
73mod runtime_support;
74mod schema;
75mod security;
76mod security_help;
77mod setup_hooks;
78mod signal;
79mod suppressions;
80mod task_matrix;
81mod telemetry;
82mod trace_chain;
83mod update_check;
84use fallow_engine::validate;
85use fallow_engine::vital_signs;
86mod cli_telemetry;
87mod viz;
88mod watch;
89
90use check::{CheckOptions, IssueFilters, TraceOptions};
91/// Structured error output for CLI and JSON formats.
92pub(crate) mod error;
93#[cfg(test)]
94use cli_format::parse_format_arg;
95use cli_format::{Format, FormatConfig};
96use cli_hooks::{HooksCli, run_hooks_command};
97use cli_impact::{ImpactCli, ImpactCrossRepoOpts, ImpactSortCli, dispatch_impact};
98use cli_production::{ProductionModes, resolve_production_modes};
99#[cfg(test)]
100use cli_startup::build_tracing_filter;
101use cli_startup::{
102    bare_coverage_subcommand_error_message, cli_has_bare_coverage_input, parse_cli_args,
103    run_pre_dispatch_checks, setup_tracing, validate_inputs,
104};
105#[cfg(test)]
106use cli_telemetry::TelemetryRun;
107#[cfg(test)]
108use cli_telemetry::{fallback_failure_reason_for, telemetry_workflow_for_command};
109use cli_telemetry::{record_run_epilogue, start_telemetry_run};
110use dupes::{DupesMode, DupesOptions};
111use error::emit_error;
112use health::{HealthOptions, SortBy};
113use list::ListOptions;
114pub(crate) use runtime_support::{AnalysisKind, GroupBy};
115pub(crate) use runtime_support::{
116    ConfigLoadOptions, LoadConfigArgs, build_ownership_resolver, load_config,
117    load_config_for_analysis,
118};
119#[cfg(test)]
120use security_help::{SECURITY_UNSUPPORTED_GLOBAL_LONGS, SecurityHelpTarget};
121use security_help::{render_security_help, security_help_target};
122
123const DEFAULT_MIN_INVOCATIONS_HOT: u64 = 100;
124
125const TOP_LEVEL_HELP_TEMPLATE: &str =
126    "{about-with-newline}\n{usage-heading} {usage}{after-help}\n\nOptions:\n{options}";
127
128// Macros instead of consts so `concat!` can assemble the short (`-h`) and
129// long (`--help`) after-help surfaces from the same source fragments.
130macro_rules! top_level_task_cheat_sheet {
131    () => {
132        "\
133When the agent is about to...
134  delete an \"unused\" export or file        fallow dead-code --trace <file>:<export>
135  prove exact TypeScript symbol consumers  fallow dead-code --type-aware --symbol-impact <file>:<export-or-class.method>
136  delete an \"unused\" dependency            fallow dead-code --trace-dependency <name>
137  commit or open a PR                      fallow audit --base <ref>
138  prioritize refactoring                   fallow health --hotspots --targets
139  ask who owns code                        fallow health --ownership
140  check untested-but-reachable code        fallow health --coverage-gaps
141  consolidate duplication                  fallow dupes --trace dup:<fingerprint>
142  find feature flags                       fallow flags
143  check architecture rules before editing  fallow guard <files>
144  surface security candidates              fallow security
145  inspect a target before editing          fallow inspect --file <path>
146  understand a finding                     fallow explain <issue-type>
147  scope a monorepo                         --workspace <glob> / --changed-workspaces <ref>"
148    };
149}
150
151macro_rules! top_level_core_command_groups {
152    () => {
153        "\
154Analysis:
155  dead-code      Analyze unused code, dependency hygiene, and architecture cycles
156  dupes          Find copy-paste and structural code duplication
157  health         Analyze complexity, maintainability, hotspots, and coverage gaps
158  flags          Detect feature flag usage patterns
159  security       Surface local security candidates for agent verification (opt-in)
160  audit          Review changed files for dead code, complexity, duplication, and styling
161
162Workflow:
163  watch          Re-run analysis as files change
164  fix            Auto-fix safe unused-code findings"
165    };
166}
167
168macro_rules! top_level_extended_command_groups {
169    () => {
170        "\
171Project inspection:
172  list              List discovered files, entry points, plugins, boundaries, and workspaces
173  inspect           Inspect one file or exported symbol as a bundled evidence query
174  trace             Trace a symbol's call chain (best-effort, syntactic)
175  guard             Show which architecture rules apply to files before editing
176  decision-surface  Surface the structural decisions a change embeds (advisory)
177  workspaces        Show monorepo workspace discovery diagnostics
178  explain           Explain one issue type without running analysis
179  suppressions      List active fallow-ignore suppression markers
180  impact            Show what fallow has done for you (opt-in, local-only)
181  viz               Generate an interactive HTML map of the codebase
182
183Setup and configuration:
184  init              Create a fallow config, optionally with a Git hook
185  audit-cache       Maintain reusable audit base-snapshot caches
186  recommend         Recommend a project-tailored config for an agent to author
187  migrate           Migrate knip, jscpd, or stylelint config to fallow
188  config            Show the resolved config and loaded config file
189  config-schema     Print the fallow config JSON Schema
190  plugin-schema     Print the external plugin JSON Schema
191  plugin-check      Dry-run external plugins and report what they seed
192  rule-pack         Manage declarative rule packs (policy-as-code)
193  rule-pack-schema  Print the rule pack JSON Schema
194  type-aware        Inspect the optional TypeScript semantic companion
195
196Automation and CI:
197  ci             Build PR/MR feedback envelopes
198  ci-template    Print or vendor CI integration templates
199  report         Re-render saved JSON as GitHub or CodeClimate output
200  hooks          Install or remove fallow-managed Git and agent hooks
201  setup-hooks    Legacy agent-hook installer
202
203Runtime coverage:
204  coverage       Set up or analyze runtime coverage data
205  license        Manage the paid-feature license
206  telemetry      Manage opt-in product telemetry
207
208Reference:
209  schema         Dump the CLI interface as machine-readable JSON
210  help           Print this message or the help of a command"
211    };
212}
213
214const TOP_LEVEL_AFTER_HELP: &str = concat!(
215    top_level_task_cheat_sheet!(),
216    "\n\n",
217    top_level_core_command_groups!(),
218    "\n\nRun fallow --help for the complete command list."
219);
220
221const TOP_LEVEL_AFTER_LONG_HELP: &str = concat!(
222    top_level_task_cheat_sheet!(),
223    "\n\n",
224    top_level_core_command_groups!(),
225    "\n\n",
226    top_level_extended_command_groups!(),
227    "\n\n",
228    "When no command is given, fallow runs dead-code + dupes + health together.\n",
229    "Use --only/--skip to select specific analyses."
230);
231
232#[derive(Parser)]
233#[command(
234    name = "fallow",
235    about = "Codebase analyzer for TypeScript/JavaScript: unused code, circular dependencies, code duplication, complexity hotspots, and architecture boundary violations",
236    version,
237    disable_version_flag = true,
238    help_template = TOP_LEVEL_HELP_TEMPLATE,
239    after_help = TOP_LEVEL_AFTER_HELP,
240    after_long_help = TOP_LEVEL_AFTER_LONG_HELP
241)]
242struct Cli {
243    #[command(subcommand)]
244    command: Option<Command>,
245
246    /// Print version.
247    /// Accepts `-v`, `-V`, and `--version`; TS/JS tooling (node, npm, pnpm,
248    /// yarn, bun, tsc) uses `-v`, while `-V` matches knip/oxlint/biome.
249    #[arg(
250        short = 'v',
251        visible_short_alias = 'V',
252        long = "version",
253        action = clap::ArgAction::Version
254    )]
255    version: Option<bool>,
256
257    /// Project root directory
258    #[arg(short, long, global = true)]
259    root: Option<PathBuf>,
260
261    /// Path to config file (.fallowrc.json, .fallowrc.jsonc, fallow.toml, or .fallow.toml)
262    #[arg(short, long, global = true)]
263    config: Option<PathBuf>,
264
265    /// Allow trusted config files to extend HTTPS URLs
266    #[arg(hide_short_help = true, long, global = true)]
267    allow_remote_extends: bool,
268
269    /// Output format (alias: --output)
270    #[arg(
271        short,
272        long,
273        visible_alias = "output",
274        global = true,
275        default_value = "human"
276    )]
277    format: Format,
278
279    /// Indent JSON output for manual inspection. Requires the final output format to be JSON.
280    #[arg(hide_short_help = true, long, global = true)]
281    pretty: bool,
282
283    /// Suppress progress output
284    #[arg(short, long, global = true)]
285    quiet: bool,
286
287    /// Disable incremental caching
288    #[arg(hide_short_help = true, long, global = true)]
289    no_cache: bool,
290
291    /// Number of parser threads
292    #[arg(hide_short_help = true, long, global = true)]
293    threads: Option<usize>,
294
295    /// Only report issues in files changed since this git ref (e.g., main, HEAD~5)
296    #[arg(long, visible_alias = "base", global = true)]
297    changed_since: Option<String>,
298
299    /// Unified diff for line-level scoping.
300    /// Use `-` to read from stdin. Project-level findings still bypass this
301    /// filter. When both this and `--changed-since` are set, the diff filter
302    /// wins for finding scope while `--changed-since` still drives file discovery.
303    #[arg(
304        hide_short_help = true,
305        long = "diff-file",
306        value_name = "PATH",
307        global = true
308    )]
309    diff_file: Option<PathBuf>,
310
311    /// Read the unified diff from stdin.
312    /// Equivalent to `--diff-file -`.
313    #[arg(hide_short_help = true, long = "diff-stdin", global = true)]
314    diff_stdin: bool,
315
316    /// Import change history from a `fallow-churn/v1` JSON file instead of `git
317    /// log`, powering hotspots, ownership, and bus-factor on projects with no
318    /// git repository (Yandex Arc, Mercurial, Perforce). A small wrapper
319    /// translates your VCS log into the contract. Resolved relative to `--root`.
320    /// Affects `health --hotspots` / `--ownership` / `--targets` only; `audit`,
321    /// `impact`, and `--changed-since` still require git.
322    #[arg(
323        hide_short_help = true,
324        long = "churn-file",
325        value_name = "PATH",
326        global = true
327    )]
328    churn_file: Option<PathBuf>,
329
330    /// Skip source files larger than this many megabytes (default 5) instead of
331    /// parsing them, guarding against the out-of-memory blowup a single
332    /// multi-MB generated/vendored/bundled file causes on large repos. Use `0`
333    /// for no limit. Declaration files (`.d.ts`) are always analyzed. Skipped
334    /// files are reported and excluded from every analysis. Also settable via
335    /// `FALLOW_MAX_FILE_SIZE`.
336    #[arg(
337        hide_short_help = true,
338        long = "max-file-size",
339        value_name = "MB",
340        global = true
341    )]
342    max_file_size: Option<u32>,
343
344    /// Compare against a previously saved baseline file
345    #[arg(hide_short_help = true, long, global = true)]
346    baseline: Option<PathBuf>,
347
348    /// How `--baseline` matches health findings: per file and category
349    /// (`count`, the default) or per function identity (`identity`, strict,
350    /// and only against a baseline that was saved with `--baseline-mode
351    /// identity`; such a baseline still reads in count mode).
352    ///
353    /// A finding's identity is its file path plus its function name, so
354    /// renaming or moving a function that is still in the baseline reports it
355    /// as new. Re-save the baseline after that kind of refactor. Functions
356    /// that share a name in one file, and unnamed functions, share one
357    /// identity and can mask each other.
358    ///
359    /// Defaults to `count` when omitted. Saving without the flag refuses to
360    /// overwrite a baseline that carries identities; pass `--baseline-mode
361    /// count` explicitly to downgrade such a baseline on purpose.
362    #[arg(
363        hide_short_help = true,
364        long = "baseline-mode",
365        value_enum,
366        global = true
367    )]
368    baseline_mode: Option<BaselineModeArg>,
369
370    /// Correlate this run with a previous telemetry analysis run.
371    ///
372    /// Used only for opt-in telemetry follow-up measurement. The value is not
373    /// interpreted as a path, repository, package, or user identifier. Hidden
374    /// from `--help`; agents receive the correlation token from JSON output.
375    #[arg(long, global = true, value_name = "RUN_ID", hide = true)]
376    parent_run: Option<String>,
377
378    /// Save the current results as a baseline file
379    #[arg(hide_short_help = true, long, global = true)]
380    save_baseline: Option<PathBuf>,
381
382    /// Production mode: exclude test/story/dev files, only start/build scripts,
383    /// report type-only dependencies
384    #[arg(long, global = true)]
385    production: bool,
386
387    /// Force production mode OFF for every analysis, overriding a project
388    /// config's `production: true` (and `FALLOW_PRODUCTION`). Conflicts with
389    /// `--production`.
390    #[arg(
391        hide_short_help = true,
392        long = "no-production",
393        global = true,
394        conflicts_with = "production"
395    )]
396    no_production: bool,
397
398    /// Run dead-code analysis in production mode when using bare combined mode.
399    #[arg(hide_short_help = true, long = "production-dead-code")]
400    production_dead_code: bool,
401
402    /// Run health analysis in production mode when using bare combined mode.
403    #[arg(hide_short_help = true, long = "production-health")]
404    production_health: bool,
405
406    /// Run duplication analysis in production mode when using bare combined mode.
407    #[arg(hide_short_help = true, long = "production-dupes")]
408    production_dupes: bool,
409
410    /// Scope output to selected workspaces.
411    /// Accepts exact names, glob patterns, and `!`-prefixed negations.
412    /// Values can be comma-separated or repeated.
413    #[arg(short, long, global = true, value_delimiter = ',')]
414    workspace: Option<Vec<String>>,
415
416    /// Scope output to workspaces touched since the given git ref.
417    /// Git is required. Mutually exclusive with `--workspace`.
418    #[arg(long, global = true, value_name = "REF")]
419    changed_workspaces: Option<String>,
420
421    /// Group output by owner or by directory.
422    #[arg(hide_short_help = true, long, global = true)]
423    group_by: Option<GroupBy>,
424
425    /// Show pipeline performance timing breakdown
426    #[arg(hide_short_help = true, long, global = true)]
427    performance: bool,
428
429    /// Include metric definitions and rule descriptions in output.
430    #[arg(hide_short_help = true, long, global = true)]
431    explain: bool,
432
433    /// Show a per-pattern breakdown for default duplicate ignores.
434    #[arg(hide_short_help = true, long, global = true)]
435    explain_skipped: bool,
436
437    /// Show only category counts without individual items
438    #[arg(hide_short_help = true, long, global = true)]
439    summary: bool,
440
441    /// CI mode: equivalent to --format sarif --fail-on-issues --quiet
442    #[arg(long, global = true)]
443    ci: bool,
444
445    /// Exit with code 1 if issues are found
446    #[arg(hide_short_help = true, long, global = true)]
447    fail_on_issues: bool,
448
449    /// Write SARIF output to a file (in addition to the primary --format output)
450    #[arg(hide_short_help = true, long, global = true, value_name = "PATH")]
451    sarif_file: Option<PathBuf>,
452
453    /// Write the report to a file instead of stdout, for any --format (no ANSI
454    /// codes). Useful on large projects where the terminal scrollback truncates
455    /// the top. Progress and the confirmation stay on stderr.
456    #[arg(short = 'o', long, global = true, value_name = "PATH")]
457    output_file: Option<PathBuf>,
458
459    /// Prefix prepended to every path in the CI-facing formats
460    /// (`github-annotations`, `github-summary`, `codeclimate`,
461    /// `review-github`, `review-gitlab`). CI platforms address files by
462    /// repository-root-relative path, so when the analyzed project lives in a
463    /// subdirectory (e.g. `packages/app/`), paths need that offset. fallow
464    /// detects the offset via the git toplevel automatically; this flag
465    /// overrides the detection. Pass an empty string to disable rebasing and
466    /// emit paths relative to `--root`.
467    #[arg(
468        hide_short_help = true,
469        long = "report-path-prefix",
470        visible_alias = "annotations-path-prefix",
471        global = true,
472        value_name = "PREFIX"
473    )]
474    report_path_prefix: Option<String>,
475
476    /// Fail if issue count increased beyond tolerance compared to a regression baseline.
477    #[arg(hide_short_help = true, long, global = true)]
478    fail_on_regression: bool,
479
480    /// Allowed issue count increase before a regression is flagged.
481    #[arg(
482        hide_short_help = true,
483        long,
484        global = true,
485        value_name = "TOLERANCE",
486        default_value = "0"
487    )]
488    tolerance: String,
489
490    /// Path to the regression baseline file.
491    #[arg(hide_short_help = true, long, global = true, value_name = "PATH")]
492    regression_baseline: Option<PathBuf>,
493
494    /// Save the current issue counts as a regression baseline. Omit PATH to
495    /// update regression.baseline in the discovered fallow config, or create
496    /// .fallowrc.json when none exists. Provide PATH to write a standalone file.
497    #[expect(
498        clippy::option_option,
499        reason = "clap pattern: None=not passed, Some(None)=flag only (write to config), Some(Some(path))=write to file"
500    )]
501    #[arg(hide_short_help = true, long, global = true, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
502    save_regression_baseline: Option<Option<String>>,
503
504    /// Run only specific analyses when no subcommand is given.
505    #[arg(long, value_delimiter = ',')]
506    only: Vec<AnalysisKind>,
507
508    /// Skip specific analyses when no subcommand is given.
509    #[arg(long, value_delimiter = ',')]
510    skip: Vec<AnalysisKind>,
511
512    /// Override duplication detection mode in combined mode.
513    #[arg(hide_short_help = true, long = "dupes-mode", global = true)]
514    dupes_mode: Option<DupesMode>,
515
516    /// Enable function-scoped near-miss clone detection in combined mode.
517    #[arg(hide_short_help = true, long = "dupes-near", global = true)]
518    dupes_near: bool,
519
520    /// Override duplication threshold in combined mode.
521    #[arg(hide_short_help = true, long = "dupes-threshold", global = true)]
522    dupes_threshold: Option<f64>,
523
524    /// Override the minimum token count for clones in combined mode.
525    #[arg(hide_short_help = true, long = "dupes-min-tokens", global = true)]
526    dupes_min_tokens: Option<usize>,
527
528    /// Override the minimum line count for clones in combined mode.
529    #[arg(hide_short_help = true, long = "dupes-min-lines", global = true)]
530    dupes_min_lines: Option<usize>,
531
532    /// Override the minimum clone occurrences in combined mode (must be >= 2).
533    #[arg(hide_short_help = true, long = "dupes-min-occurrences", global = true, value_parser = parse_min_occurrences)]
534    dupes_min_occurrences: Option<usize>,
535
536    /// Only report cross-directory duplicates in combined mode.
537    #[arg(hide_short_help = true, long = "dupes-skip-local", global = true)]
538    dupes_skip_local: bool,
539
540    /// Enable cross-language duplicate detection in combined mode.
541    #[arg(hide_short_help = true, long = "dupes-cross-language", global = true)]
542    dupes_cross_language: bool,
543
544    /// Exclude module wiring from duplicate detection in combined mode
545    /// (default). Pass `--dupes-no-ignore-imports` to count it again.
546    #[arg(hide_short_help = true, long = "dupes-ignore-imports", global = true)]
547    dupes_ignore_imports: bool,
548
549    /// Count module wiring as clone candidates in combined mode (opt out of the
550    /// default exclusion).
551    #[arg(
552        hide_short_help = true,
553        long = "dupes-no-ignore-imports",
554        global = true,
555        conflicts_with = "dupes_ignore_imports"
556    )]
557    dupes_no_ignore_imports: bool,
558
559    /// Compute health score in combined mode.
560    #[arg(hide_short_help = true, long)]
561    score: bool,
562
563    /// Compare current health metrics against the most recent saved snapshot.
564    #[arg(hide_short_help = true, long)]
565    trend: bool,
566
567    /// Save a vital signs snapshot for trend tracking in combined mode.
568    /// Provide a path or omit for the default `.fallow/snapshots/` location.
569    #[expect(
570        clippy::option_option,
571        reason = "clap pattern: None=not passed, Some(None)=default path, Some(Some(path))=custom path"
572    )]
573    #[arg(hide_short_help = true, long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
574    save_snapshot: Option<Option<String>>,
575
576    /// Path to Istanbul coverage data for exact CRAP scores in combined mode.
577    /// Also settable via `FALLOW_COVERAGE` or `health.coverage`.
578    #[arg(hide_short_help = true, long, value_name = "PATH")]
579    coverage: Option<PathBuf>,
580
581    /// Absolute prefix to strip from Istanbul file paths in combined mode.
582    /// Also settable via `FALLOW_COVERAGE_ROOT` or `health.coverageRoot`.
583    #[arg(hide_short_help = true, long = "coverage-root", value_name = "PATH")]
584    coverage_root: Option<PathBuf>,
585
586    /// Report unused exports in entry files instead of auto-marking them as used.
587    #[arg(hide_short_help = true, long, global = true)]
588    include_entry_exports: bool,
589
590    /// Opt in to TypeScript semantic analysis for project-wide symbol evidence.
591    /// This does not emit compiler diagnostics or typed lint findings.
592    #[arg(hide_short_help = true, long, global = true)]
593    type_aware: bool,
594
595    /// Disable TypeScript semantic analysis even when `typeAware.enabled` or
596    /// `FALLOW_TYPE_AWARE` opts in, keeping this run fully syntactic.
597    #[arg(
598        hide_short_help = true,
599        long,
600        global = true,
601        conflicts_with = "type_aware"
602    )]
603    no_type_aware: bool,
604
605    /// TypeScript project config to use for type-aware analysis (repeatable).
606    #[arg(hide_short_help = true, long, global = true, value_name = "PATH", action = clap::ArgAction::Append)]
607    type_aware_project: Vec<PathBuf>,
608
609    /// Decide whether incomplete type-aware analysis is advisory or gating.
610    #[arg(hide_short_help = true, long, global = true, value_enum)]
611    type_aware_require: Option<TypeAwareRequireArg>,
612}
613
614impl Cli {
615    /// Tri-state CLI override for type-aware analysis: `Some(true)` for
616    /// `--type-aware`, `Some(false)` for `--no-type-aware`, `None` when
617    /// neither flag was passed (environment and config decide).
618    const fn type_aware_override(&self) -> Option<bool> {
619        if self.no_type_aware {
620            Some(false)
621        } else if self.type_aware {
622            Some(true)
623        } else {
624            None
625        }
626    }
627}
628
629#[derive(Clone, Copy, Subcommand)]
630enum TypeAwareCli {
631    /// Report companion availability and version compatibility without analysis.
632    Status,
633}
634
635#[derive(Subcommand)]
636enum Command {
637    /// Analyze project for unused code and circular dependencies
638    #[command(name = "dead-code", alias = "check")]
639    Check {
640        /// Only report unused files
641        #[arg(long)]
642        unused_files: bool,
643
644        /// Only report unused exports
645        #[arg(long)]
646        unused_exports: bool,
647
648        /// Only report unused dependencies
649        #[arg(long)]
650        unused_deps: bool,
651
652        /// Only report unused type exports
653        #[arg(long)]
654        unused_types: bool,
655
656        /// Opt in to private type leak API hygiene findings and only report that issue type
657        #[arg(long)]
658        private_type_leaks: bool,
659
660        /// Only report unused enum members
661        #[arg(long)]
662        unused_enum_members: bool,
663
664        /// Only report unused class members
665        #[arg(long)]
666        unused_class_members: bool,
667
668        /// Only report unused store members
669        #[arg(long)]
670        unused_store_members: bool,
671
672        /// Only report unprovided injects
673        #[arg(long)]
674        unprovided_injects: bool,
675
676        /// Only report unrendered components
677        #[arg(long)]
678        unrendered_components: bool,
679
680        /// Only report unused component props
681        #[arg(long)]
682        unused_component_props: bool,
683
684        /// Only report unused component emits
685        #[arg(long)]
686        unused_component_emits: bool,
687
688        /// Only report unused component inputs
689        #[arg(long)]
690        unused_component_inputs: bool,
691
692        /// Only report unused component outputs
693        #[arg(long)]
694        unused_component_outputs: bool,
695
696        /// Only report unused Svelte dispatched events
697        #[arg(long)]
698        unused_svelte_events: bool,
699
700        /// Only report unused server actions
701        #[arg(long)]
702        unused_server_actions: bool,
703
704        /// Only report unused SvelteKit load() data keys
705        #[arg(long)]
706        unused_load_data_keys: bool,
707
708        /// Only report unresolved imports
709        #[arg(long)]
710        unresolved_imports: bool,
711
712        /// Only report unlisted dependencies
713        #[arg(long)]
714        unlisted_deps: bool,
715
716        /// Only report duplicate exports
717        #[arg(long)]
718        duplicate_exports: bool,
719
720        /// Only report circular dependencies
721        #[arg(long)]
722        circular_deps: bool,
723
724        /// Only report re-export cycles
725        #[arg(long)]
726        re_export_cycles: bool,
727
728        /// Only report boundary violations
729        #[arg(long)]
730        boundary_violations: bool,
731
732        /// Only report rule-pack policy violations
733        #[arg(long)]
734        policy_violations: bool,
735
736        /// Only report stale suppressions
737        #[arg(long)]
738        stale_suppressions: bool,
739
740        /// Only report unused pnpm catalog entries
741        #[arg(long)]
742        unused_catalog_entries: bool,
743
744        /// Only report empty pnpm catalog groups
745        #[arg(long)]
746        empty_catalog_groups: bool,
747
748        /// Only report unresolved pnpm catalog references
749        #[arg(long)]
750        unresolved_catalog_references: bool,
751
752        /// Only report unused pnpm dependency overrides
753        #[arg(long)]
754        unused_dependency_overrides: bool,
755
756        /// Only report misconfigured pnpm dependency overrides
757        #[arg(long)]
758        misconfigured_dependency_overrides: bool,
759
760        /// Also run duplication analysis and cross-reference with dead code
761        #[arg(long)]
762        include_dupes: bool,
763
764        /// Trace why an export is used/unused (format: `FILE:EXPORT_NAME`)
765        #[arg(long, value_name = "FILE:EXPORT")]
766        trace: Option<String>,
767
768        /// Trace all edges for a file (imports, exports, importers)
769        #[arg(long, value_name = "PATH")]
770        trace_file: Option<String>,
771
772        /// Trace where a dependency is used
773        #[arg(long, value_name = "PACKAGE")]
774        trace_dependency: Option<String>,
775
776        /// Compute the impact closure for a file (the transitive
777        /// affected-but-not-in-diff set + coordination gap). Walks reverse-deps
778        /// and re-export chains; powers the `inspect_target` MCP tool.
779        #[arg(long, value_name = "PATH")]
780        impact_closure: Option<String>,
781
782        /// Compute exact-symbol consumers, affected files, and targeted tests.
783        #[arg(long, value_name = "FILE:EXPORT")]
784        symbol_impact: Option<String>,
785
786        /// Show only the top N items per category
787        #[arg(long)]
788        top: Option<usize>,
789
790        /// Only report issues in the specified file(s). Accepts multiple values.
791        /// The full project graph is still built, but only issues in matching files
792        /// are reported. Useful for lint-staged pre-commit hooks.
793        #[arg(long, value_name = "PATH")]
794        file: Vec<std::path::PathBuf>,
795    },
796
797    /// Watch for changes and re-run analysis
798    Watch {
799        /// Don't clear the screen between re-analyses
800        #[arg(long)]
801        no_clear: bool,
802    },
803
804    /// Inspect the optional TypeScript semantic companion.
805    TypeAware {
806        #[command(subcommand)]
807        subcommand: TypeAwareCli,
808    },
809
810    /// Inspect one file or exported symbol as a bundled evidence query
811    Inspect {
812        /// File to inspect.
813        #[arg(
814            long,
815            value_name = "PATH",
816            conflicts_with = "symbol",
817            required_unless_present = "symbol"
818        )]
819        file: Option<String>,
820
821        /// Exported symbol to inspect, formatted as FILE:EXPORT.
822        #[arg(long, value_name = "FILE:EXPORT", conflicts_with = "file")]
823        symbol: Option<String>,
824
825        /// OPT-IN: also attach the best-effort symbol-level call chain
826        /// (`fallow trace`) as the `symbol_chain` evidence section. Only
827        /// meaningful for a `--symbol` target. Default off (best-effort,
828        /// syntactic, OFF the ranked path).
829        #[arg(long)]
830        symbol_chain: bool,
831
832        /// OPT-IN: attach target-level git churn evidence from the health
833        /// hotspot subsystem. Default off to avoid git-history latency.
834        #[arg(long)]
835        churn: bool,
836    },
837
838    /// Trace a symbol's call chain (best-effort, syntactic; OFF the ranked path)
839    ///
840    /// Walks callers UP (modules that import the symbol) and callees DOWN
841    /// (import-symbol edges + intra-module call sites) via the module graph,
842    /// bounded by `--depth`. Symbol-level chains are labeled best-effort per
843    /// ADR-001: resolved-vs-unresolved callees are reported honestly, never
844    /// silently dropped. The result is its OWN surface, NOT folded into the
845    /// ranked brief and NEVER an input to the focus map / ranking.
846    Trace {
847        /// Target symbol, formatted as FILE:SYMBOL (e.g. src/utils.ts:formatDate).
848        #[arg(value_name = "FILE:SYMBOL")]
849        symbol: String,
850
851        /// Walk UP to callers (modules that import the symbol). When neither
852        /// `--callers` nor `--callees` is set, both directions are walked.
853        #[arg(long)]
854        callers: bool,
855
856        /// Walk DOWN to callees (the symbol's module's import-symbol edges plus
857        /// unresolved call sites). When neither flag is set, both are walked.
858        #[arg(long)]
859        callees: bool,
860
861        /// Chain depth bound for both directions (default 2). Symbol-level is
862        /// best-effort, so a shallow bound keeps the trace legible.
863        #[arg(long, value_name = "N")]
864        depth: Option<u32>,
865    },
866
867    /// Auto-fix issues: remove unused exports, dependencies, and enum
868    /// members; add duplicate-export rules to a fallow config file.
869    ///
870    /// When no fallow config exists outside a monorepo subpackage, a
871    /// fresh `.fallowrc.json` is created from the same scaffolding
872    /// `fallow init` would emit (framework detection, `$schema`,
873    /// `entry`, etc.) and the duplicate-export rules are layered on
874    /// top. Inside a monorepo subpackage the create-fallback refuses
875    /// and points at the workspace root. Pass `--no-create-config` to
876    /// opt out of the create-fallback (recommended for pre-commit
877    /// hooks, CI bots, and `fallow watch`).
878    ///
879    /// Use `--dry-run` to preview source-file edits and config-file
880    /// diffs without writing.
881    Fix {
882        /// Dry run, show what would be changed without modifying files
883        #[arg(long)]
884        dry_run: bool,
885
886        /// Skip confirmation prompt (required in non-TTY environments like CI or AI agents)
887        #[arg(long, alias = "force")]
888        yes: bool,
889
890        /// Refuse to create a new fallow config file when none exists.
891        /// Use this from pre-commit hooks, CI bots, and `fallow watch`
892        /// where silently materialising a new top-level config file would
893        /// surprise the user. The duplicate-export config-add path is
894        /// skipped with an explanatory message; source-file edits proceed
895        /// normally.
896        #[arg(long)]
897        no_create_config: bool,
898    },
899
900    /// Initialize a .fallowrc.json configuration file, AGENTS.md guide, or git
901    /// pre-commit hook. Use `.fallowrc.jsonc` for editor-native JSON-with-comments
902    /// support; both extensions are auto-discovered.
903    ///
904    /// `--hooks` scaffolds a shell-level Git pre-commit hook under
905    /// `.git/hooks/` that runs fallow on changed files. The clearer hook
906    /// namespace is `fallow hooks install --target git`; `init --hooks`
907    /// remains as a convenience during project initialization.
908    Init {
909        /// Generate TOML instead of JSONC
910        #[arg(long)]
911        toml: bool,
912
913        /// Scaffold a starter AGENTS.md guidance file for coding agents
914        #[arg(long, conflicts_with_all = ["toml", "hooks", "branch"])]
915        agents: bool,
916
917        /// Scaffold a shell-level pre-commit git hook in `.git/hooks/` that
918        /// runs fallow on changed files. Alias for
919        /// `fallow hooks install --target git`.
920        #[arg(long)]
921        hooks: bool,
922
923        /// Fallback base branch/ref for the pre-commit hook when no upstream is set
924        #[arg(long, requires = "hooks")]
925        branch: Option<String>,
926
927        /// Record that this project deliberately stays unconfigured: persists a
928        /// decline so the first-contact setup hint and the `setup` next-step
929        /// stop appearing here. Writes no config file; idempotent
930        #[arg(long, conflicts_with_all = ["toml", "agents", "hooks", "branch"])]
931        decline: bool,
932    },
933
934    /// Install or remove fallow-managed Git and agent hooks.
935    ///
936    /// Use `fallow hooks install --target git` for a shell-level Git
937    /// pre-commit hook. Use `fallow hooks install --target agent` for a
938    /// Claude Code / Codex gate that blocks agent `git commit` / `git push`
939    /// commands until `fallow audit` passes.
940    Hooks {
941        #[command(subcommand)]
942        subcommand: HooksCli,
943    },
944
945    /// CI helpers for PR/MR feedback envelopes.
946    Ci {
947        #[command(subcommand)]
948        subcommand: CiCli,
949    },
950
951    /// Print the JSON Schema for fallow configuration files
952    ConfigSchema,
953
954    /// Print the JSON Schema for external plugin files
955    PluginSchema,
956
957    /// Dry-run external plugins: report what each activated and seeded
958    PluginCheck,
959
960    /// Print the JSON Schema for rule pack files
961    RulePackSchema,
962
963    /// Manage declarative rule packs (policy-as-code)
964    RulePack {
965        #[command(subcommand)]
966        subcommand: RulePackCli,
967    },
968
969    /// Show which architecture rules apply to files before changing them.
970    Guard {
971        /// Files to report on (root-relative or absolute; may not exist yet)
972        #[arg(required = true, num_args = 1..)]
973        files: Vec<String>,
974    },
975
976    /// Show the resolved config and which config file was loaded
977    ///
978    /// Walks up from the project root looking for `.fallowrc.json`,
979    /// `.fallowrc.jsonc`, `fallow.toml`, or `.fallow.toml`, resolves `extends`, and prints
980    /// the final config as JSON. Use `--path` to print only the config
981    /// file path (useful in shell scripts). The default view always exits 0:
982    /// it prints the loaded config, or, on a zero-config project, the effective
983    /// defaults (fully supported). `--path` exits 3 when no config file exists,
984    /// since there is no path to report.
985    ///
986    /// Precedence is first-match-wins per directory, in the order
987    /// `.fallowrc.json` > `.fallowrc.jsonc` > `fallow.toml` > `.fallow.toml`,
988    /// walking up to the workspace root. `.fallowrc.json` accepts JSONC
989    /// (comments and trailing commas); `.fallowrc.jsonc` is identical in
990    /// behavior, the extension only signals to editors that comments are
991    /// expected. If two config files coexist in one directory, fallow loads the
992    /// higher-precedence one and warns on stderr naming the file it ignored.
993    Config {
994        /// Print only the config file path (one line, no JSON)
995        #[arg(long)]
996        path: bool,
997    },
998
999    /// Recommend a project-tailored config for an agent to author.
1000    ///
1001    /// Read-only. Inspects the project (frameworks, workspace layout, tooling)
1002    /// and emits what fallow detected, a safe proposed config, and a list of
1003    /// decisions split into auto (decided from detection), default (a disclosed
1004    /// overridable default), and taste (a genuinely subjective choice surfaced
1005    /// to the user as an open question). Honors `--root` and `--format`.
1006    Recommend,
1007
1008    /// List discovered entry points, files, plugins, boundaries, and workspaces.
1009    List {
1010        /// Show entry points
1011        #[arg(long)]
1012        entry_points: bool,
1013
1014        /// Show all discovered files
1015        #[arg(long)]
1016        files: bool,
1017
1018        /// Show active plugins
1019        #[arg(long)]
1020        plugins: bool,
1021
1022        /// Show architecture boundary zones, rules, and per-zone file counts
1023        #[arg(long)]
1024        boundaries: bool,
1025
1026        /// Show monorepo workspaces and any workspace-discovery diagnostics
1027        /// (malformed package.json, unreachable glob matches, missing
1028        /// tsconfig references).
1029        #[arg(long)]
1030        workspaces: bool,
1031    },
1032
1033    /// Show monorepo workspaces and any workspace-discovery diagnostics.
1034    ///
1035    /// Equivalent to `fallow list --workspaces`. Use this dedicated form
1036    /// when introspecting only the workspace topology (other `list`
1037    /// sections stay hidden).
1038    Workspaces,
1039
1040    /// Find code duplication / clones across the project
1041    Dupes {
1042        /// Detection mode: strict, mild, weak, or semantic
1043        /// (defaults to the value in `.fallowrc.jsonc`, or `mild` if unset).
1044        #[arg(long)]
1045        mode: Option<DupesMode>,
1046
1047        /// Enable function-scoped near-miss clone detection.
1048        #[arg(long)]
1049        near: bool,
1050
1051        /// Minimum token count for a clone
1052        /// (defaults to the value in `.fallowrc.jsonc`, or `50` if unset).
1053        #[arg(long)]
1054        min_tokens: Option<usize>,
1055
1056        /// Minimum line count for a clone
1057        /// (defaults to the value in `.fallowrc.jsonc`, or `5` if unset).
1058        #[arg(long)]
1059        min_lines: Option<usize>,
1060
1061        /// Minimum number of occurrences before a clone group is reported.
1062        /// Raise to focus on widespread copy-paste worth refactoring and skip
1063        /// pair-only clones.
1064        /// (defaults to the value in `.fallowrc.jsonc`, or `2` if unset).
1065        #[arg(long, value_parser = parse_min_occurrences)]
1066        min_occurrences: Option<usize>,
1067
1068        /// Fail if duplication exceeds this percentage (0 = no limit)
1069        /// (defaults to the value in `.fallowrc.jsonc`, or `0` if unset).
1070        #[arg(long)]
1071        threshold: Option<f64>,
1072
1073        /// Only report cross-directory duplicates
1074        #[arg(long)]
1075        skip_local: bool,
1076
1077        /// Enable cross-language detection (strip TS type annotations for TS↔JS matching)
1078        #[arg(long)]
1079        cross_language: bool,
1080
1081        /// Exclude module wiring from clone detection (default; covers imports,
1082        /// re-exports, and top-level static require bindings). Pass
1083        /// `--no-ignore-imports` to count it again.
1084        #[arg(long)]
1085        ignore_imports: bool,
1086
1087        /// Count module wiring as clone candidates (opt out of the default
1088        /// exclusion).
1089        #[arg(long, conflicts_with = "ignore_imports")]
1090        no_ignore_imports: bool,
1091
1092        /// Show only the N highest-ranked clone groups. Ranking combines clone
1093        /// size, occurrence count, and capped directory or line spread.
1094        #[arg(long)]
1095        top: Option<usize>,
1096
1097        /// Trace all clones at a specific location (format: `FILE:LINE`)
1098        #[arg(long, value_name = "FILE:LINE")]
1099        trace: Option<String>,
1100    },
1101
1102    /// Analyze function complexity (cyclomatic + cognitive)
1103    ///
1104    /// By default, shows all existing sections: health score, complexity findings,
1105    /// file scores, hotspots, and refactoring targets. When any section flag is
1106    /// specified, only those sections are shown.
1107    Health {
1108        /// Maximum cyclomatic complexity threshold (overrides config)
1109        #[arg(long)]
1110        max_cyclomatic: Option<u16>,
1111
1112        /// Maximum cognitive complexity threshold (overrides config)
1113        #[arg(long)]
1114        max_cognitive: Option<u16>,
1115
1116        /// Maximum CRAP score threshold (overrides config, default 30.0).
1117        /// Functions meeting or exceeding this score are reported alongside
1118        /// complexity findings. Pair with `--coverage` for accurate scoring.
1119        #[arg(long)]
1120        max_crap: Option<f64>,
1121
1122        /// Show only the N most complex functions
1123        #[arg(long)]
1124        top: Option<usize>,
1125
1126        /// Sort by: cyclomatic (default), cognitive, lines, or severity
1127        #[arg(long, default_value = "cyclomatic")]
1128        sort: SortBy,
1129
1130        /// Show only complexity findings (functions exceeding thresholds).
1131        /// By default all sections are shown; use this to select only complexity.
1132        #[arg(long)]
1133        complexity: bool,
1134
1135        /// Include the per-decision-point complexity breakdown (`contributions[]`)
1136        /// on each complexity finding in `--format json` output. Each entry names
1137        /// the construct (if, else-if, loop, boolean operator, ...) and its
1138        /// cyclomatic/cognitive weight, so a consumer can explain WHY a function
1139        /// scored high. Used by the VS Code inline editor breakdown. Off by
1140        /// default to keep CI/default output lean.
1141        #[arg(long)]
1142        complexity_breakdown: bool,
1143
1144        /// Show only per-file health scores (fan-in, fan-out, dead code ratio, maintainability index).
1145        /// Requires full analysis pipeline (graph + dead code detection).
1146        /// Sorted by risk-aware triage concern: lower MI and higher CRAP risk first.
1147        /// --sort and --baseline apply to complexity findings only, not file scores.
1148        #[arg(long)]
1149        file_scores: bool,
1150
1151        /// Show only static test coverage gaps: runtime files and exports with no
1152        /// dependency path from any discovered test root. Requires full analysis pipeline.
1153        #[arg(long)]
1154        coverage_gaps: bool,
1155
1156        /// Show only hotspots: files that are both complex and frequently changing.
1157        /// Combines git churn history with complexity data. Requires a git repository.
1158        #[arg(long)]
1159        hotspots: bool,
1160
1161        /// Attach ownership signals to hotspot entries: bus factor, contributor
1162        /// count, declared CODEOWNERS owner, and ownership drift. Implies
1163        /// `--hotspots`. Requires a git repository.
1164        #[arg(long)]
1165        ownership: bool,
1166
1167        /// Privacy mode for author emails emitted with `--ownership`.
1168        /// Defaults to `handle` (local-part only). Use `raw` for OSS repos
1169        /// where authors are public, or `anonymized` to emit non-reversible
1170        /// pseudonyms in regulated environments. Implies `--ownership`.
1171        #[arg(long, value_name = "MODE", value_enum)]
1172        ownership_emails: Option<EmailModeArg>,
1173
1174        /// Show only refactoring targets: ranked recommendations based on complexity,
1175        /// coupling, churn, and dead code signals. Requires full analysis pipeline.
1176        #[arg(long)]
1177        targets: bool,
1178
1179        /// Show advisory project-local public-signature type coupling. Requires
1180        /// type-aware analysis and does not change the health score.
1181        #[arg(long)]
1182        type_coupling: bool,
1183
1184        /// Add structural CSS analytics: specificity hotspots, !important density,
1185        /// over-complex selectors, deep nesting, and conservative cleanup
1186        /// candidates. Standard CSS is parsed structurally; preprocessor sources
1187        /// are scanned only where fallow can avoid expanding Sass/Less semantics.
1188        #[arg(long)]
1189        css: bool,
1190
1191        /// Filter refactoring targets by effort level (low, medium, high).
1192        /// Implies --targets.
1193        #[arg(long, value_enum)]
1194        effort: Option<EffortFilter>,
1195
1196        /// Show only the project health score (0–100) with letter grade (A/B/C/D/F).
1197        /// The score is included by default when no section flags are set.
1198        #[arg(long)]
1199        score: bool,
1200
1201        /// Fail if the health score is below this threshold (0-100).
1202        /// Implies --score. The authoritative CI quality gate: when set,
1203        /// complexity findings become informational and the exit code is
1204        /// driven solely by the score (so --min-score 0 always exits 0).
1205        /// Composes with --min-severity (fails if either gate trips). Plain
1206        /// `fallow health` (no gate flag) stays advisory and exits 1 on any
1207        /// finding; for a gate on newly-introduced complexity use
1208        /// `fallow audit --gate new-only`.
1209        #[arg(long, value_name = "N")]
1210        min_score: Option<f64>,
1211
1212        /// Only exit with error for findings at or above this severity.
1213        /// Use --min-severity critical to ignore moderate/high findings in CI.
1214        /// Composes with --min-score (the run fails if either gate trips).
1215        #[arg(long, value_name = "LEVEL", value_enum)]
1216        min_severity: Option<HealthSeverityCli>,
1217
1218        /// Print the score and findings but never fail CI (always exit 0).
1219        /// Advisory mode for surfacing health in logs without blocking.
1220        /// Mutually exclusive with --min-score and --min-severity.
1221        #[arg(long)]
1222        report_only: bool,
1223
1224        /// Git history window for hotspot analysis (default: 6m).
1225        /// Accepts durations (6m, 90d, 1y, 2w) or ISO dates (2025-06-01).
1226        #[arg(long, value_name = "DURATION")]
1227        since: Option<String>,
1228
1229        /// Minimum number of commits for a file to be included in hotspot ranking (default: 3)
1230        #[arg(long, value_name = "N")]
1231        min_commits: Option<u32>,
1232
1233        /// Save a vital signs snapshot for trend tracking.
1234        /// Defaults to `.fallow/snapshots/{timestamp}.json` if no path is given.
1235        /// Forces file-scores, hotspot, and score computation for complete metrics.
1236        #[expect(
1237            clippy::option_option,
1238            reason = "clap pattern: None=not passed, Some(None)=flag only, Some(Some(path))=with value"
1239        )]
1240        #[arg(long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
1241        save_snapshot: Option<Option<String>>,
1242
1243        /// Compare current metrics against the most recent saved snapshot.
1244        /// Reads from `.fallow/snapshots/` and shows per-metric deltas with
1245        /// directional indicators. Implies --score.
1246        #[arg(long)]
1247        trend: bool,
1248
1249        /// Path to coverage data (coverage-final.json) for exact per-function
1250        /// CRAP scores. Generate with `jest --coverage`, `vitest run --coverage
1251        /// --provider istanbul`, or any Istanbul-compatible tool. Requires
1252        /// Istanbul format (not v8/c8 native format). Accepts a single
1253        /// Istanbul coverage map JSON file or a directory containing
1254        /// coverage-final.json. Use --coverage-root when the file was generated
1255        /// in a different environment (CI runner, Docker). Affects CRAP scores
1256        /// only, not --coverage-gaps. Also configurable via FALLOW_COVERAGE env var.
1257        #[arg(long, value_name = "PATH")]
1258        coverage: Option<PathBuf>,
1259
1260        /// Absolute prefix to strip from file paths in coverage data before
1261        /// prepending the project root. Use when coverage was generated in a
1262        /// different environment (CI runner, Docker). Example: if coverage paths
1263        /// start with /home/runner/work/myapp and the project root is ./,
1264        /// pass --coverage-root /home/runner/work/myapp.
1265        #[arg(long, value_name = "PATH")]
1266        coverage_root: Option<PathBuf>,
1267
1268        /// File or directory containing runtime coverage input. Accepts a
1269        /// V8 coverage directory, a single V8 JSON file, or a single
1270        /// Istanbul coverage map JSON file (commonly coverage-final.json).
1271        #[arg(long, value_name = "PATH")]
1272        runtime_coverage: Option<PathBuf>,
1273
1274        /// Threshold for hot-path classification
1275        #[arg(long, default_value_t = 100)]
1276        min_invocations_hot: u64,
1277
1278        /// Minimum total trace volume before the sidecar allows high-confidence
1279        /// `safe_to_delete` / `review_required` verdicts. Below this the
1280        /// sidecar caps confidence at `medium` to protect against overconfident
1281        /// verdicts on new or low-traffic services. Omit to use the sidecar's
1282        /// spec default (5000).
1283        #[arg(long, value_name = "N")]
1284        min_observation_volume: Option<u32>,
1285
1286        /// Fraction of total trace count below which an invoked function is
1287        /// classified as `low_traffic` rather than `active`. Expressed as a
1288        /// decimal (e.g. `0.001` for 0.1%). Omit to use the sidecar's spec
1289        /// default (0.001).
1290        #[arg(long, value_name = "RATIO")]
1291        low_traffic_threshold: Option<f64>,
1292    },
1293
1294    /// Detect feature flag patterns in the codebase
1295    ///
1296    /// Identifies environment variable flags (process.env.FEATURE_*),
1297    /// SDK calls from common providers, and config object patterns (opt-in).
1298    /// Reports flag locations, detection confidence, and cross-reference
1299    /// with dead code findings.
1300    Flags {
1301        /// Show only the top N flags
1302        #[arg(long)]
1303        top: Option<usize>,
1304    },
1305
1306    /// List active fallow-ignore suppression markers (read-only inventory)
1307    ///
1308    /// Shows every `fallow-ignore-next-line` and `fallow-ignore-file` marker
1309    /// present in analyzed files, grouped per file with line, kind, level,
1310    /// and reason, plus project totals and a stale cross-reference against
1311    /// this run's stale-suppression findings. A governance surface, not a
1312    /// detector: always exits 0. Honors `--root`, `--format {human,json}`,
1313    /// `--workspace`, `--changed-workspaces`, `--changed-since`, and
1314    /// `--quiet`.
1315    Suppressions {
1316        /// Only list suppressions in the specified files. Accepts multiple values.
1317        #[arg(long, value_name = "PATH")]
1318        file: Vec<std::path::PathBuf>,
1319    },
1320
1321    /// Explain one fallow issue type without running an analysis.
1322    ///
1323    /// Prints the rule rationale, a worked example, fix guidance, and the
1324    /// relevant docs URL. Accepts values like `unused-export`,
1325    /// `fallow/unused-export`, `unused exports`, and `code duplication`.
1326    Explain {
1327        /// Issue type, issue label, or rule id to explain
1328        #[arg(required = true, num_args = 1.., value_name = "ISSUE_TYPE")]
1329        issue_type: Vec<String>,
1330    },
1331
1332    /// Audit changed files for dead code, complexity, duplication, and styling.
1333    ///
1334    /// Purpose-built for reviewing AI-generated code and PR quality gates.
1335    /// Combines dead-code + complexity + duplication + styling scoped to
1336    /// changed files and returns a verdict (pass/warn/fail).
1337    ///
1338    /// `fallow audit` answers "will CI block this?": it gates (exit 1 on a
1339    /// fail verdict). The `review` alias plus `--brief` answer "where do I
1340    /// look?": the same analysis rendered as a deterministic orientation brief
1341    /// that ALWAYS exits 0, so a reviewer or agent can read it regardless of
1342    /// the verdict. `--format` is orthogonal to `--brief`.
1343    /// When `--changed-since`/`--base` is unset, the base is the git merge-base
1344    /// against the branch's upstream or the remote default (`origin/HEAD`,
1345    /// `origin/main`, `origin/master`); set `FALLOW_AUDIT_BASE` to pin it.
1346    /// By default, only findings introduced by the changeset affect the verdict;
1347    /// inherited findings are reported with new-vs-inherited attribution and
1348    /// individual JSON findings include `introduced: true/false`. Use
1349    /// `--gate all` or `[audit] gate = "all"` to fail on every finding in
1350    /// changed files without running the extra base-snapshot attribution pass.
1351    ///
1352    /// The global --baseline / --save-baseline flags are rejected on audit.
1353    /// Use --dead-code-baseline, --health-baseline, and --dupes-baseline
1354    /// (or their config equivalents) because each sub-analysis uses a
1355    /// different baseline format.
1356    #[command(visible_alias = "review")]
1357    Audit {
1358        /// Run dead-code analysis in production mode for this audit.
1359        #[arg(long = "production-dead-code")]
1360        production_dead_code: bool,
1361
1362        /// Run health analysis in production mode for this audit.
1363        #[arg(long = "production-health")]
1364        production_health: bool,
1365
1366        /// Run duplication analysis in production mode for this audit.
1367        #[arg(long = "production-dupes")]
1368        production_dupes: bool,
1369
1370        /// Compare dead-code issues against a saved baseline
1371        /// (produced by `fallow dead-code --save-baseline`).
1372        #[arg(long)]
1373        dead_code_baseline: Option<PathBuf>,
1374
1375        /// Compare health findings against a saved baseline
1376        /// (produced by `fallow health --save-baseline`).
1377        #[arg(long)]
1378        health_baseline: Option<PathBuf>,
1379
1380        /// Compare duplication clone groups against a saved baseline
1381        /// (produced by `fallow dupes --save-baseline`).
1382        #[arg(long)]
1383        dupes_baseline: Option<PathBuf>,
1384
1385        /// Maximum CRAP score threshold (overrides config, default 30.0).
1386        /// Functions meeting or exceeding this score cause audit to fail.
1387        /// Pair with `--coverage` for accurate scoring.
1388        #[arg(long)]
1389        max_crap: Option<f64>,
1390
1391        /// Path to Istanbul-format coverage data (coverage-final.json) for
1392        /// accurate per-function CRAP scores in the health sub-analysis. Also
1393        /// configurable via FALLOW_COVERAGE.
1394        #[arg(long, value_name = "PATH")]
1395        coverage: Option<PathBuf>,
1396
1397        /// Absolute prefix to strip from coverage data paths before CRAP matching.
1398        /// Use when coverage was generated under a different checkout root in CI or Docker.
1399        #[arg(long, value_name = "PATH")]
1400        coverage_root: Option<PathBuf>,
1401
1402        /// Disable styling analytics in audit.
1403        #[arg(long = "no-css")]
1404        no_css: bool,
1405
1406        /// Enable deep CSS analysis for audit explicitly: project-wide styling
1407        /// reachability, narrowed back to changed anchors. Deep CSS is on by
1408        /// default; use this to override `audit.cssDeep = false`.
1409        #[arg(long)]
1410        css_deep: bool,
1411
1412        /// Disable deep CSS analysis while keeping local styling analytics on.
1413        #[arg(long = "no-css-deep")]
1414        no_css_deep: bool,
1415
1416        /// Which findings affect the audit verdict.
1417        ///
1418        /// new-only (default): fail only on findings introduced by the current
1419        /// changeset. all: fail on every finding in changed files and skip
1420        /// base-snapshot attribution.
1421        #[arg(long, value_enum)]
1422        gate: Option<AuditGateArg>,
1423
1424        /// Paid runtime-coverage sidecar input. Accepts a V8 directory, a
1425        /// single V8 JSON file, or an Istanbul coverage map JSON. Spawns
1426        /// the `fallow-cov` sidecar as part of the audit pipeline so the
1427        /// `hot-path-touched` verdict surfaces alongside dead-code and
1428        /// complexity findings without requiring a second `fallow health`
1429        /// invocation in CI. License-gated; the verdict is informational
1430        /// (no exit code change) until a future `--gate hot-path-touched`
1431        /// knob lands.
1432        #[arg(long, value_name = "PATH")]
1433        runtime_coverage: Option<PathBuf>,
1434
1435        /// Threshold for hot-path classification, forwarded to the sidecar
1436        /// when `--runtime-coverage` is set.
1437        #[arg(long, default_value_t = 100)]
1438        min_invocations_hot: u64,
1439
1440        /// Internal marker identifying a gate run (e.g. `pre-commit`), set by
1441        /// the generated git hook so Fallow Impact can record a containment
1442        /// event when the gate blocks then clears. Hidden; never changes the
1443        /// verdict, exit code, or output.
1444        #[arg(long, value_name = "MARKER", hide = true)]
1445        gate_marker: Option<String>,
1446
1447        /// Render the deterministic review brief instead of the gating audit
1448        /// report. The brief answers "where do I look?" rather than "will CI
1449        /// block this?", runs the same analysis, and ALWAYS exits 0 (the
1450        /// verdict is carried informationally). Implied by `fallow review`.
1451        /// Orthogonal to `--format`.
1452        #[arg(long)]
1453        brief: bool,
1454
1455        /// Cap on the number of consequential structural decisions surfaced in
1456        /// the review brief's decision surface (the working-memory limit).
1457        /// Default 4; clamped to the 3-5 band (4 plus or minus 1). Only
1458        /// consulted on the brief path.
1459        #[arg(
1460            long,
1461            value_name = "N",
1462            default_value_t = audit_decision_surface::DEFAULT_DECISION_CAP
1463        )]
1464        max_decisions: usize,
1465
1466        /// Emit the agent-contract WALKTHROUGH GUIDE: the current digest
1467        /// (brief + decision surface), the review direction, the JSON schema the
1468        /// agent must return, and a deterministic graph-snapshot hash pinned into
1469        /// the digest. The digest is built from the graph only (PR prose is never
1470        /// folded in, so it is injection-resistant). Implies the brief; always
1471        /// exits 0. A thin agent skill calls this to fetch the current guide,
1472        /// produces judgment JSON, then reopens with `--walkthrough-file`.
1473        #[arg(long, conflicts_with_all = ["walkthrough_file", "walkthrough"])]
1474        walkthrough_guide: bool,
1475
1476        /// Ingest an agent's judgment JSON and POST-VALIDATE it against the
1477        /// LIVE graph. Rejects any judgment whose `signal_id` fallow did not emit
1478        /// (anti-hallucination); refuses the whole payload as stale when the
1479        /// echoed graph-snapshot hash no longer matches (the tree moved). The
1480        /// verifier is the graph, not a second model. Implies the brief; always
1481        /// exits 0. The agent's free-text framing is fenced as non-deterministic
1482        /// and never gates or auto-posts.
1483        #[arg(long, value_name = "PATH")]
1484        walkthrough_file: Option<PathBuf>,
1485
1486        /// Render the existing walkthrough guide as a staged HUMAN terminal tour
1487        /// (Stage 1 load-bearing / Stage 2 mechanical), or markdown with
1488        /// `--format markdown`. Implies the brief; always exits 0.
1489        /// `--format json --walkthrough` emits the same agent-contract JSON as
1490        /// `--walkthrough-guide`.
1491        #[arg(long, conflicts_with_all = ["walkthrough_guide", "walkthrough_file"])]
1492        walkthrough: bool,
1493
1494        /// Record one or more changed files as VIEWED in the local walkthrough
1495        /// viewed-state ledger (`.fallow/walkthrough-state.json`), then render the
1496        /// tour. Files already viewed (and still current) collapse into the
1497        /// Cleared panel. Repeatable. Stale marks (the tree moved) are ignored on
1498        /// render but never deleted. Only consulted on the `--walkthrough` path.
1499        #[arg(long, value_name = "PATH")]
1500        mark_viewed: Vec<PathBuf>,
1501
1502        /// Expand the Cleared panel in the human/markdown walkthrough tour: list
1503        /// each de-prioritized and already-viewed file instead of the collapsed
1504        /// one-line summary. Only consulted on the `--walkthrough` path.
1505        #[arg(long)]
1506        show_cleared: bool,
1507
1508        /// Expand the de-prioritized units in the review brief's weighted
1509        /// focus map ("show me what you de-prioritized"). The `deprioritized`
1510        /// escape-hatch list is ALWAYS present in `--format json` regardless; this
1511        /// flag only re-expands the collapse-by-default human focus render. Only
1512        /// consulted on the brief path.
1513        #[arg(long)]
1514        show_deprioritized: bool,
1515    },
1516
1517    /// Maintain reusable audit base-snapshot caches.
1518    AuditCache {
1519        #[command(subcommand)]
1520        subcommand: AuditCacheCli,
1521    },
1522
1523    /// Surface the consequential structural DECISIONS a change embeds (the apex
1524    /// of the review brief), each framed as a judgment question with the routed
1525    /// expert to ask.
1526    ///
1527    /// The product's decision surface: a ranked, capped (4 plus or minus 1),
1528    /// signal_id-anchored set of the SOLID-3 decisions (coupling/boundary,
1529    /// exports-aware public-API/contract, dependency). Runs the same changed-code
1530    /// analysis as `fallow review` but emits ONLY the decisions, separable and
1531    /// cheap. Every decision is suppressible with `// fallow-ignore`. Always
1532    /// exits 0 (advisory, never a gate). Use `--base` / `--changed-since` to pick
1533    /// the comparison point, exactly like `fallow audit`.
1534    DecisionSurface {
1535        /// Cap on the number of surfaced decisions (the working-memory limit).
1536        /// Default 4; clamped to the 3-5 band (4 plus or minus 1).
1537        #[arg(
1538            long,
1539            value_name = "N",
1540            default_value_t = audit_decision_surface::DEFAULT_DECISION_CAP
1541        )]
1542        max_decisions: usize,
1543    },
1544
1545    /// Show what fallow has done for you: how many issues it is surfacing, the
1546    /// trend since the last recorded run, and how many commits it contained at
1547    /// the pre-commit gate.
1548    ///
1549    /// Local-only and opt-in: enable per project with `fallow impact enable`, or
1550    /// turn it on everywhere with `fallow impact default on`, then let your
1551    /// `fallow audit` / pre-commit gate runs build history. History is stored in
1552    /// your user config dir (never written into the repo) and forced off in CI.
1553    /// Impact never uploads anything and never affects exit codes.
1554    Impact {
1555        #[command(subcommand)]
1556        subcommand: Option<ImpactCli>,
1557        /// Aggregate every tracked project into one cross-repo roll-up
1558        /// ("what has fallow done for me across all my repos"). Reads the
1559        /// user config dir; ignores `--root`. Cannot combine with a subcommand.
1560        #[arg(long)]
1561        all: bool,
1562        /// Row ordering for `--all` (default: most recently recorded first).
1563        #[arg(long, value_enum, default_value_t = ImpactSortCli::Recent)]
1564        sort: ImpactSortCli,
1565        /// Cap the number of `--all` rows printed (grand totals still reflect
1566        /// every tracked project).
1567        #[arg(long)]
1568        limit: Option<usize>,
1569    },
1570
1571    /// Surface local security candidates for downstream agent verification (opt-in).
1572    ///
1573    /// Ships three complementary surfaces. (1) The graph-structural
1574    /// `client-server-leak` rule: a `"use client"` file that transitively imports
1575    /// a module reading a non-public env secret through `process.env` or
1576    /// `import.meta.env`. (2) The data-driven
1577    /// `tainted-sink` catalogue: syntactic sink sites matched against a CWE
1578    /// catalogue (`security_matchers.toml`) spanning categories such as
1579    /// dangerous-html, template-escape-bypass, command-injection, code-injection,
1580    /// dynamic-regex, redos-regex, resource-amplification, dynamic-module-load,
1581    /// sql-injection, ssrf, path-traversal, header-injection, open-redirect,
1582    /// cleartext-transport, electron-unsafe-webpreferences,
1583    /// world-writable-permission, insecure-temp-file,
1584    /// mysql-multiple-statements, mass-assignment, weak-crypto,
1585    /// deprecated-cipher, insecure-randomness,
1586    /// unsafe-buffer-alloc, unsafe-deserialization, prototype-pollution,
1587    /// zip-slip, nosql-injection, ssti, xxe, xpath-injection, and
1588    /// webview-injection. (3) `hardcoded-secret`,
1589    /// an include-required
1590    /// category for provider-prefix literals and high-entropy literals assigned
1591    /// to secret-shaped identifiers. It never runs from raw entropy alone. All
1592    /// findings are CANDIDATES for verification, NOT verified vulnerabilities.
1593    /// This command is the only
1594    /// surface for security findings; they never appear under bare `fallow` or
1595    /// the `audit` gate. Build-config and test files are excluded, and public
1596    /// env prefixes such as `NEXT_PUBLIC_` and `VITE_` are treated as public.
1597    /// Honors
1598    /// `--root`, `--format {human,json,sarif}`, `--changed-since`, `--file`, `--gate`, `--diff-file`,
1599    /// `--diff-stdin`, `--workspace`, `--changed-workspaces`, `--ci`,
1600    /// `--fail-on-issues`, `--sarif-file`, `--summary`, `--explain`, and `--surface`.
1601    Security {
1602        #[command(subcommand)]
1603        subcommand: Option<SecuritySubcommand>,
1604        /// Paid runtime-coverage sidecar input. Accepts a V8 directory, a
1605        /// single V8 JSON file, or an Istanbul coverage map JSON. When set,
1606        /// `fallow security` annotates tainted-sink candidates with production
1607        /// runtime state and uses that state as an additive ranking signal.
1608        #[arg(long, value_name = "PATH")]
1609        runtime_coverage: Option<PathBuf>,
1610        /// Threshold for hot-path classification, forwarded to the sidecar
1611        /// when `--runtime-coverage` is set.
1612        #[arg(long, default_value_t = 100)]
1613        min_invocations_hot: u64,
1614        /// Only report security candidates in or reachable from the specified files.
1615        /// The full project graph is still built, but output is scoped to matching
1616        /// finding anchors or trace hops. Accepts multiple values.
1617        #[arg(long, value_name = "PATH")]
1618        file: Vec<std::path::PathBuf>,
1619        /// Opt-in regression gate: fail (exit 8) only when the change introduces a
1620        /// NEW security-sink candidate in the changed lines, not on the whole
1621        /// candidate backlog. Requires a diff source: `--changed-since <ref>`,
1622        /// `--diff-file <path>`, or `--diff-stdin`. There is deliberately no `all`
1623        /// mode (gating on the full backlog is the anti-feature this gate avoids).
1624        #[arg(long, value_name = "MODE")]
1625        gate: Option<security::SecurityGateArg>,
1626        /// Include the agent-facing attack-surface inventory in JSON output.
1627        #[arg(long)]
1628        surface: bool,
1629    },
1630
1631    /// Render a saved `--format json` results file in another format without
1632    /// re-running analysis (analyze once, render annotations and the job
1633    /// summary from the same file). Supports `github-annotations`,
1634    /// `github-summary`, `codeclimate`, and `sarif`.
1635    Report {
1636        /// Path to a fallow JSON results file produced by `--format json`
1637        /// (dead-code, dupes, health, audit, security, or bare combined).
1638        #[arg(long, value_name = "PATH")]
1639        from: PathBuf,
1640    },
1641    /// Dump fallow's capability manifest (CLI commands and flags, issue types, MCP tools, framework plugins, env vars) as machine-readable JSON for agent introspection. Always JSON, regardless of --format
1642    Schema,
1643
1644    /// Print or vendor CI integration templates.
1645    ///
1646    /// Use `fallow ci-template gitlab` to print the GitLab CI template, or
1647    /// `fallow ci-template gitlab --vendor` to write the template plus the
1648    /// bash helper files that enable MR comments without downloading from
1649    /// raw.githubusercontent.com at pipeline runtime.
1650    CiTemplate {
1651        #[command(subcommand)]
1652        subcommand: CiTemplateCli,
1653    },
1654
1655    /// Migrate configuration from knip, jscpd, or stylelint to fallow
1656    Migrate {
1657        /// Generate `fallow.toml` instead of JSONC
1658        #[arg(long, conflicts_with = "jsonc")]
1659        toml: bool,
1660
1661        /// Write JSONC content to `.fallowrc.jsonc` instead of `.fallowrc.json`. The
1662        /// generated content is the same JSONC (with `//` comments) either way; the
1663        /// `.jsonc` extension lets editors auto-detect JSON-with-comments syntax
1664        /// highlighting and silences linters that flag comments in `.json`. Without
1665        /// `--jsonc` or `--toml`, fallow auto-mirrors the source extension: a
1666        /// `knip.jsonc` migration writes `.fallowrc.jsonc`, a `knip.json` migration
1667        /// writes `.fallowrc.json`.
1668        #[arg(long)]
1669        jsonc: bool,
1670
1671        /// Only preview the generated config without writing
1672        #[arg(long)]
1673        dry_run: bool,
1674
1675        /// Path to source config file (auto-detect if not specified)
1676        #[arg(long, value_name = "PATH")]
1677        from: Option<PathBuf>,
1678    },
1679
1680    /// Manage the license for continuous/cloud runtime monitoring.
1681    ///
1682    /// Verification is offline against an Ed25519 public key compiled into
1683    /// the binary. The license file lives at `~/.fallow/license.jwt` (or
1684    /// `$FALLOW_LICENSE_PATH`); `$FALLOW_LICENSE` env var takes precedence
1685    /// and is the recommended path for shared CI runners.
1686    License {
1687        #[command(subcommand)]
1688        subcommand: LicenseCli,
1689    },
1690
1691    /// Manage opt-in product telemetry.
1692    ///
1693    /// Telemetry is off by default. It never collects repository names, paths,
1694    /// package names, source code, config values, raw errors, or raw agent
1695    /// detection evidence. Use `fallow telemetry inspect --example` to see the
1696    /// documented payload shape, or prefix a real command with
1697    /// `FALLOW_TELEMETRY=inspect` to print the exact payload without sending.
1698    Telemetry {
1699        #[command(subcommand)]
1700        subcommand: TelemetryCli,
1701    },
1702
1703    /// Runtime coverage workflow.
1704    ///
1705    /// `setup` is the resumable single-entry-point first-run flow: license
1706    /// check → sidecar install → coverage recipe → analysis. Spec:
1707    /// `.internal/spec-runtime-coverage-phase-2.md` (private repo).
1708    Coverage {
1709        #[command(subcommand)]
1710        subcommand: CoverageCli,
1711    },
1712
1713    /// Install or remove a Claude Code PreToolUse hook that gates
1714    /// `git commit` / `git push` on `fallow audit`, so the agent cleans
1715    /// findings before the command runs.
1716    ///
1717    /// This is the legacy AGENT-level enforcement command. Prefer
1718    /// `fallow hooks install --target agent` for new setup. It writes into
1719    /// `.claude/settings.json` + `.claude/hooks/fallow-gate.sh` (and
1720    /// optionally an `AGENTS.md` managed block for Codex). For a
1721    /// shell-level Git pre-commit hook in `.git/hooks/`, see
1722    /// `fallow hooks install --target git` instead. Both targets can be used
1723    /// together: git hooks catch human commits, agent hooks catch agent
1724    /// commits.
1725    ///
1726    /// See `/integrations/claude-hooks` in the docs for the full recipe.
1727    SetupHooks {
1728        /// Target a specific agent surface (default: auto-detect).
1729        #[arg(long, value_enum)]
1730        agent: Option<setup_hooks::HookAgentArg>,
1731
1732        /// Print what would be written or removed without touching the filesystem.
1733        #[arg(long)]
1734        dry_run: bool,
1735
1736        /// Overwrite a user-edited hook script, invalid settings.json, or
1737        /// remove a user-edited script during uninstall.
1738        #[arg(long)]
1739        force: bool,
1740
1741        /// Write to the user's home directory instead of the project root.
1742        #[arg(long)]
1743        user: bool,
1744
1745        /// Append `.claude/` to the project's `.gitignore`.
1746        #[arg(long)]
1747        gitignore_claude: bool,
1748
1749        /// Remove the fallow-gate handler, hook script, and AGENTS.md
1750        /// managed block instead of installing them. Idempotent: reports
1751        /// "unchanged" when nothing to remove.
1752        #[arg(long)]
1753        uninstall: bool,
1754    },
1755
1756    /// Generate an interactive HTML map of the codebase
1757    Viz {
1758        /// Output file path (default: fallow-viz.html in project root)
1759        #[arg(long = "out", value_name = "PATH")]
1760        output: Option<PathBuf>,
1761
1762        /// Don't open the output file in the browser
1763        #[arg(long)]
1764        no_open: bool,
1765
1766        /// Visualization output format
1767        #[arg(long = "viz-format", default_value = "html")]
1768        viz_format: viz::VizFormat,
1769    },
1770}
1771
1772#[derive(Subcommand)]
1773enum SecuritySubcommand {
1774    /// Render verifier-retained survivor candidates from fallow output plus verifier verdicts.
1775    Survivors {
1776        /// Raw `fallow security --format json` candidate output.
1777        #[arg(long, value_name = "PATH")]
1778        candidates: PathBuf,
1779        /// Verifier verdict JSON file.
1780        #[arg(long, value_name = "PATH")]
1781        verdicts: PathBuf,
1782        /// Fail when any candidate has no matching verdict.
1783        #[arg(long)]
1784        require_verdict_for_each_candidate: bool,
1785    },
1786    /// Group unresolved security callees into actionable blind-spot output.
1787    #[command(name = "blind-spots")]
1788    BlindSpots {
1789        /// Scope diagnostics to selected files.
1790        #[arg(long, value_name = "PATH")]
1791        file: Vec<PathBuf>,
1792    },
1793}
1794
1795#[derive(clap::Subcommand)]
1796enum AuditCacheCli {
1797    /// Remove reusable audit caches owned by an explicit project root.
1798    Remove {
1799        /// Print what would be removed without touching the filesystem.
1800        #[arg(long)]
1801        dry_run: bool,
1802
1803        /// Confirm removal in non-interactive environments.
1804        #[arg(long, alias = "force")]
1805        yes: bool,
1806    },
1807}
1808
1809#[derive(clap::Subcommand)]
1810enum LicenseCli {
1811    /// Activate a license JWT.
1812    ///
1813    /// JWT input precedence: positional arg > `--from-file` > stdin (`-`).
1814    /// All paths normalize whitespace before crypto verification.
1815    Activate {
1816        /// JWT as a positional argument.
1817        #[arg(value_name = "JWT")]
1818        jwt: Option<String>,
1819
1820        /// Path to a file containing the JWT.
1821        #[arg(long, value_name = "PATH")]
1822        from_file: Option<PathBuf>,
1823
1824        /// Read JWT from stdin.
1825        #[arg(long, conflicts_with_all = ["jwt", "from_file"])]
1826        stdin: bool,
1827
1828        /// Start a 30-day email-gated trial in one step.
1829        ///
1830        /// The trial endpoint is rate-limited to 5 requests per hour per IP.
1831        /// In CI or behind a shared NAT, start the trial from a developer
1832        /// machine and set FALLOW_LICENSE (or FALLOW_LICENSE_PATH) on the
1833        /// runner instead of re-running `activate --trial` per job.
1834        #[arg(long, requires = "email")]
1835        trial: bool,
1836
1837        /// Email address for the trial flow.
1838        #[arg(long, value_name = "ADDR")]
1839        email: Option<String>,
1840    },
1841    /// Show the active license tier, seats, features, and days remaining.
1842    Status,
1843    /// Fetch a fresh JWT from `api.fallow.cloud` (network-only).
1844    Refresh,
1845    /// Remove the local license file.
1846    Deactivate,
1847}
1848
1849#[derive(Clone, Copy, clap::Subcommand)]
1850enum TelemetryCli {
1851    /// Show effective telemetry state, precedence, and controls.
1852    Status,
1853    /// Enable opt-in telemetry in the user-level fallow config.
1854    Enable,
1855    /// Disable telemetry in the user-level fallow config.
1856    Disable,
1857    /// Explain inspect mode or print example payloads.
1858    Inspect {
1859        /// Print documented example payloads and field purposes.
1860        #[arg(long)]
1861        example: bool,
1862    },
1863}
1864
1865#[derive(clap::Subcommand)]
1866enum CiTemplateCli {
1867    /// Print or vendor the GitLab CI template and MR integration helpers.
1868    Gitlab {
1869        /// Write ci/ and action/ helper files under DIR instead of printing the template.
1870        ///
1871        /// Passing --vendor without a DIR writes into the current directory.
1872        #[arg(long, value_name = "DIR", num_args = 0..=1, default_missing_value = ".")]
1873        vendor: Option<PathBuf>,
1874
1875        /// Overwrite existing files that differ from the bundled template.
1876        #[arg(long)]
1877        force: bool,
1878    },
1879}
1880
1881#[derive(clap::Subcommand)]
1882enum CoverageCli {
1883    /// Resumable first-run setup: license + sidecar + recipe + analysis.
1884    Setup {
1885        /// Accept all prompts automatically.
1886        #[arg(short = 'y', long)]
1887        yes: bool,
1888
1889        /// Print instructions instead of prompting.
1890        #[arg(long)]
1891        non_interactive: bool,
1892
1893        /// Emit deterministic setup instructions as JSON. Implies --non-interactive.
1894        #[arg(long)]
1895        json: bool,
1896    },
1897    /// Analyze runtime coverage from a local artifact or explicit cloud source.
1898    ///
1899    /// Cloud mode is opt-in only. `FALLOW_API_KEY` by itself never selects
1900    /// cloud mode; pass `--cloud` / `--runtime-coverage-cloud`, or set
1901    /// `FALLOW_RUNTIME_COVERAGE_SOURCE=cloud`.
1902    Analyze {
1903        /// File or directory containing local runtime coverage input.
1904        #[arg(long, value_name = "PATH", conflicts_with = "cloud")]
1905        runtime_coverage: Option<PathBuf>,
1906
1907        /// Fetch latest runtime facts from fallow cloud for the selected repo.
1908        #[arg(long, visible_alias = "runtime-coverage-cloud")]
1909        cloud: bool,
1910
1911        /// Fallow cloud API key. Precedence: this flag > $FALLOW_API_KEY.
1912        #[arg(long, value_name = "KEY")]
1913        api_key: Option<String>,
1914
1915        /// Override the fallow cloud base URL.
1916        #[arg(long, value_name = "URL")]
1917        api_endpoint: Option<String>,
1918
1919        /// Repository identifier, for example `owner/repo`.
1920        ///
1921        /// Defaults to $FALLOW_REPO, then the parsed origin URL from
1922        /// `git remote get-url origin`. Slashes are percent-encoded as one
1923        /// URL segment when calling the cloud runtime-context endpoint.
1924        #[arg(long, value_name = "OWNER/REPO")]
1925        repo: Option<String>,
1926
1927        /// Optional monorepo/project disambiguator.
1928        #[arg(long, value_name = "ID")]
1929        project_id: Option<String>,
1930
1931        /// Runtime observation window to request from cloud (1..=90 days).
1932        #[arg(long, value_name = "DAYS", default_value_t = 30)]
1933        coverage_period: u16,
1934
1935        /// Optional runtime environment filter.
1936        #[arg(long, value_name = "ENV")]
1937        environment: Option<String>,
1938
1939        /// Optional commit SHA filter for cloud runtime facts.
1940        #[arg(long, value_name = "SHA")]
1941        commit_sha: Option<String>,
1942
1943        /// Analyze production code only.
1944        #[arg(long)]
1945        production: bool,
1946
1947        /// Threshold for hot-path classification.
1948        #[arg(long, default_value_t = 100)]
1949        min_invocations_hot: u64,
1950
1951        /// Minimum total trace volume before high-confidence verdicts.
1952        #[arg(long, value_name = "N")]
1953        min_observation_volume: Option<u32>,
1954
1955        /// Fraction of total trace count below which an invoked function is low traffic.
1956        #[arg(long, value_name = "RATIO")]
1957        low_traffic_threshold: Option<f64>,
1958
1959        /// Show only the top N runtime findings and hot paths.
1960        #[arg(long)]
1961        top: Option<usize>,
1962
1963        /// Show the first-class blast-radius section in human output.
1964        #[arg(long)]
1965        blast_radius: bool,
1966
1967        /// Show the first-class importance section in human output.
1968        #[arg(long)]
1969        importance: bool,
1970    },
1971    /// Upload a static function inventory to fallow cloud (Production
1972    /// Coverage, paid). Unlocks the `untracked` filter on the dashboard by
1973    /// pairing runtime coverage data with the AST view of "every function
1974    /// that exists". See <https://docs.fallow.tools/analysis/runtime-coverage>.
1975    ///
1976    /// This command makes network calls to fallow cloud. `fallow dead-code`
1977    /// stays offline.
1978    ///
1979    /// Exit codes: 0 ok · 7 network · 10 validation · 11 payload too large
1980    /// · 12 auth rejected · 13 server error.
1981    UploadInventory {
1982        /// Fallow cloud API key (bearer token).
1983        ///
1984        /// Precedence: this flag > $FALLOW_API_KEY. Generate at
1985        /// <https://fallow.cloud/settings#api-keys>.
1986        ///
1987        /// Security: prefer $FALLOW_API_KEY on shared CI runners. Passing a
1988        /// secret on the command line may be visible to other processes via
1989        /// `ps` and can leak into shell history or process audit logs.
1990        #[arg(long, value_name = "KEY")]
1991        api_key: Option<String>,
1992
1993        /// Override the fallow cloud base URL.
1994        ///
1995        /// Useful for staging and on-premise deployments. Also respects
1996        /// $FALLOW_API_URL when this flag is not set.
1997        #[arg(long, value_name = "URL")]
1998        api_endpoint: Option<String>,
1999
2000        /// Project identifier, for example `fallow-cloud-api` or `owner/repo`.
2001        ///
2002        /// Defaults to $GITHUB_REPOSITORY, then $CI_PROJECT_PATH, then the
2003        /// parsed origin URL from `git remote get-url origin`.
2004        #[arg(long, value_name = "PROJECT_ID")]
2005        project_id: Option<String>,
2006
2007        /// Explicit git SHA for this inventory.
2008        ///
2009        /// Default: `git rev-parse HEAD`. The inventory is keyed on this
2010        /// value; the cloud back-fills hourly buckets with a matching SHA.
2011        #[arg(long, value_name = "SHA")]
2012        git_sha: Option<String>,
2013
2014        /// Proceed even when the working tree has uncommitted changes.
2015        ///
2016        /// Warning: the inventory is generated from the working copy, so it
2017        /// may not match the uploaded git SHA. Commit or stash first if you
2018        /// want a SHA-exact upload.
2019        #[arg(long)]
2020        allow_dirty: bool,
2021
2022        /// Additional glob patterns to exclude from the walk.
2023        ///
2024        /// Applied after the existing fallow ignore rules. Repeatable.
2025        #[arg(long, value_name = "GLOB", num_args = 0..)]
2026        exclude_paths: Vec<String>,
2027
2028        /// Prefix prepended to every emitted filePath so the static
2029        /// inventory joins with the runtime beacon for your deployment.
2030        /// Required for containerized deployments where the deployed
2031        /// WORKDIR rebases paths at runtime. Default: none (paths emit
2032        /// repo-relative, matching local runs and non-container CI).
2033        ///
2034        /// Common values: `/app` (typical Dockerfile), `/workspace`
2035        /// (Buildpacks / Cloud Run), `/usr/src/app` (older Node images),
2036        /// `/var/task` (Lambda), `/home/runner/work/<repo>/<repo>`
2037        /// (GitHub Actions default checkout).
2038        ///
2039        /// Must start with `/` and use POSIX separators.
2040        #[arg(long, value_name = "PREFIX")]
2041        path_prefix: Option<String>,
2042
2043        /// Print what would be uploaded and exit. No network call.
2044        #[arg(long)]
2045        dry_run: bool,
2046
2047        /// Also upload importer edges (which files import each function) so the
2048        /// cloud can show change-time blast radius. Opt-in: this builds the
2049        /// import graph by running the full static analysis, whereas the default
2050        /// upload is a fast per-file walk. The graph is cached, so a CI step that
2051        /// already ran analysis pays little extra.
2052        #[arg(long)]
2053        with_callers: bool,
2054
2055        /// Treat transient upload failures as warnings instead of errors
2056        /// (exit 0). Validation and auth errors still fail hard; this only
2057        /// downgrades transport and server errors.
2058        #[arg(long)]
2059        ignore_upload_errors: bool,
2060    },
2061    /// Upload JavaScript source maps to fallow cloud for bundled runtime coverage.
2062    ///
2063    /// Scans a build output directory for `.map` files and uploads them under
2064    /// the selected repo + git SHA. The production beacon reports bundled
2065    /// paths; the cloud resolver uses these maps to remap runtime coverage back
2066    /// to original source files.
2067    ///
2068    /// Each upload also carries the map's path relative to the repo root, so the
2069    /// source-evidence viewer can resolve a monorepo sub-package map's relative
2070    /// `sources[]` (e.g. `../../src/X`) to the package-prefixed source path
2071    /// (e.g. `dashboard/src/X`). Run from the repo root so this prefix is
2072    /// correct.
2073    UploadSourceMaps {
2074        /// Directory to scan recursively for source maps.
2075        #[arg(long, value_name = "PATH", default_value = "dist")]
2076        dir: PathBuf,
2077
2078        /// Glob pattern, relative to --dir, selecting maps to upload.
2079        #[arg(long, value_name = "GLOB", default_value = "**/*.map")]
2080        include: String,
2081
2082        /// Glob pattern, relative to --dir, selecting files to skip.
2083        ///
2084        /// Repeatable. Defaults to `**/node_modules/**`.
2085        #[arg(long, value_name = "GLOB", default_value = "**/node_modules/**")]
2086        exclude: Vec<String>,
2087
2088        /// Repo name used in the API path.
2089        ///
2090        /// Defaults to package.json repository.url, then `git remote get-url origin`.
2091        #[arg(long, value_name = "NAME")]
2092        repo: Option<String>,
2093
2094        /// Commit SHA to key uploads under.
2095        ///
2096        /// Defaults to $GITHUB_SHA, $CI_COMMIT_SHA, $COMMIT_SHA, then
2097        /// `git rev-parse HEAD`.
2098        #[arg(long, value_name = "SHA")]
2099        git_sha: Option<String>,
2100
2101        /// Override the fallow cloud base URL.
2102        #[arg(long, value_name = "URL")]
2103        endpoint: Option<String>,
2104
2105        /// Send only the basename as fileName by default.
2106        ///
2107        /// Use `--strip-path=false` when your runtime coverage reports bundle
2108        /// paths relative to the build directory, such as `assets/app.js`.
2109        #[arg(long, value_name = "BOOL", default_value_t = true, action = clap::ArgAction::Set)]
2110        strip_path: bool,
2111
2112        /// Print what would be uploaded and exit. No network call.
2113        #[arg(long)]
2114        dry_run: bool,
2115
2116        /// Parallel upload fanout.
2117        #[arg(long, value_name = "N", default_value_t = 4)]
2118        concurrency: usize,
2119
2120        /// Stop on first upload error.
2121        #[arg(long)]
2122        fail_fast: bool,
2123    },
2124    /// Upload static dead-code findings to fallow cloud for the source-evidence viewer.
2125    ///
2126    /// Runs fallow's static analysis and uploads the `unused_export` and
2127    /// `dead_file` verdicts under the selected repo + git SHA. The cloud
2128    /// overlays them on the source view alongside the runtime coverage overlay.
2129    /// Findings are replace-by-SHA: each run sends the complete set for the SHA.
2130    UploadStaticFindings {
2131        /// Fallow cloud API key (bearer token).
2132        ///
2133        /// Precedence: this flag > $FALLOW_API_KEY. Generate at
2134        /// <https://fallow.cloud/settings#api-keys>. This must be a live API
2135        /// key, not a publishable ingest key.
2136        ///
2137        /// Security: prefer $FALLOW_API_KEY on shared CI runners. Passing a
2138        /// secret on the command line may be visible to other processes via
2139        /// `ps` and can leak into shell history or process audit logs.
2140        #[arg(long, value_name = "KEY")]
2141        api_key: Option<String>,
2142
2143        /// Override the fallow cloud base URL.
2144        ///
2145        /// Useful for staging and on-premise deployments. Also respects
2146        /// $FALLOW_API_URL when this flag is not set.
2147        #[arg(long, value_name = "URL")]
2148        api_endpoint: Option<String>,
2149
2150        /// Project identifier, for example `fallow-cloud-api` or `owner/repo`.
2151        ///
2152        /// Defaults to $GITHUB_REPOSITORY, then $CI_PROJECT_PATH, then the
2153        /// parsed origin URL from `git remote get-url origin`.
2154        #[arg(long, value_name = "PROJECT_ID")]
2155        project_id: Option<String>,
2156
2157        /// Explicit git SHA for these findings.
2158        ///
2159        /// Default: `git rev-parse HEAD`. Findings are keyed on this value and
2160        /// fully replace any prior set uploaded for the same SHA.
2161        #[arg(long, value_name = "SHA")]
2162        git_sha: Option<String>,
2163
2164        /// Proceed even when the working tree has uncommitted changes.
2165        ///
2166        /// Warning: findings are generated from the working copy, so they may
2167        /// not match the uploaded git SHA. Commit or stash first if you want a
2168        /// SHA-exact upload.
2169        #[arg(long)]
2170        allow_dirty: bool,
2171
2172        /// Print what would be uploaded and exit. No network call.
2173        #[arg(long)]
2174        dry_run: bool,
2175
2176        /// Treat transient upload failures as warnings instead of errors
2177        /// (exit 0). Validation and auth errors still fail hard; this only
2178        /// downgrades transport and server errors.
2179        #[arg(long)]
2180        ignore_upload_errors: bool,
2181    },
2182}
2183
2184#[derive(Subcommand)]
2185enum CiCli {
2186    /// Compute the provider action for a rendered sticky PR summary comment.
2187    PlanPrComment {
2188        /// Path to the rendered PR comment Markdown body.
2189        #[arg(long)]
2190        body: PathBuf,
2191
2192        /// Sticky marker id used in the rendered body.
2193        #[arg(long)]
2194        marker_id: String,
2195
2196        /// Treat the rendered body as a clean no-findings result.
2197        #[arg(long)]
2198        clean: bool,
2199
2200        /// Existing provider comment id, when a matching sticky comment exists.
2201        #[arg(long)]
2202        existing_comment_id: Option<String>,
2203
2204        /// Path to the existing provider comment body. Enables unchanged-skip planning.
2205        #[arg(long)]
2206        existing_body: Option<PathBuf>,
2207    },
2208
2209    /// Post, update, or skip a rendered sticky PR summary comment.
2210    PostPrComment {
2211        /// Provider whose PR comment is being posted.
2212        #[arg(long, value_enum)]
2213        provider: CiProviderArg,
2214
2215        /// Pull request number (GitHub).
2216        #[arg(long)]
2217        pr: Option<String>,
2218
2219        /// Merge request IID (GitLab).
2220        #[arg(long)]
2221        mr: Option<String>,
2222
2223        /// Path to the rendered PR comment Markdown body.
2224        #[arg(long)]
2225        body: PathBuf,
2226
2227        /// Path to the typed PR comment envelope JSON, when available.
2228        #[arg(long)]
2229        envelope: Option<PathBuf>,
2230
2231        /// Sticky marker id used in the rendered body.
2232        #[arg(long)]
2233        marker_id: String,
2234
2235        /// Treat the rendered body as a clean no-findings result.
2236        #[arg(long)]
2237        clean: bool,
2238
2239        /// GitHub repository in owner/name form. Defaults to GH_REPO or GITHUB_REPOSITORY.
2240        #[arg(long)]
2241        repo: Option<String>,
2242
2243        /// GitLab project id or path. Defaults to CI_PROJECT_ID.
2244        #[arg(long = "project-id")]
2245        project_id: Option<String>,
2246
2247        /// Provider API base URL. Defaults to github.com.
2248        #[arg(long = "api-url")]
2249        api_url: Option<String>,
2250
2251        /// Compute the post plan without creating or updating the provider comment.
2252        #[arg(long)]
2253        dry_run: bool,
2254    },
2255
2256    /// Post a rendered review envelope as a provider review or summary comment.
2257    PostReview {
2258        /// Provider whose review envelope is being posted.
2259        #[arg(long, value_enum)]
2260        provider: CiProviderArg,
2261
2262        /// Pull request number (GitHub).
2263        #[arg(long)]
2264        pr: Option<String>,
2265
2266        /// Merge request IID (GitLab).
2267        #[arg(long)]
2268        mr: Option<String>,
2269
2270        /// Path to a review-github or review-gitlab JSON envelope.
2271        #[arg(long)]
2272        envelope: PathBuf,
2273
2274        /// GitHub repository in owner/name form. Defaults to GH_REPO or GITHUB_REPOSITORY.
2275        #[arg(long)]
2276        repo: Option<String>,
2277
2278        /// GitLab project id or path. Defaults to CI_PROJECT_ID.
2279        #[arg(long = "project-id")]
2280        project_id: Option<String>,
2281
2282        /// Provider API base URL. Defaults to github.com or CI_API_V4_URL/gitlab.com.
2283        #[arg(long = "api-url")]
2284        api_url: Option<String>,
2285
2286        /// Compute the post plan without creating provider comments.
2287        #[arg(long)]
2288        dry_run: bool,
2289    },
2290
2291    /// Post a GitHub Check Run from a typed PR decision surface.
2292    PostCheckRun {
2293        /// Provider whose check run is being posted. Only GitHub is supported.
2294        #[arg(long, value_enum)]
2295        provider: CiProviderArg,
2296
2297        /// Path to a fallow-pr-decision JSON sidecar.
2298        #[arg(long)]
2299        decision: PathBuf,
2300
2301        /// GitHub repository in owner/name form.
2302        #[arg(long)]
2303        repo: String,
2304
2305        /// Head SHA the check run should attach to.
2306        #[arg(long = "head-sha")]
2307        head_sha: String,
2308
2309        /// Provider API base URL. Defaults to github.com.
2310        #[arg(long = "api-url")]
2311        api_url: Option<String>,
2312
2313        /// Post one check run per decision gate instead of one aggregate check.
2314        #[arg(long = "split-gates")]
2315        split_gates: bool,
2316
2317        /// Print the check run payload without posting it.
2318        #[arg(long)]
2319        dry_run: bool,
2320    },
2321
2322    /// Validate a rendered review envelope and compute a stable reconcile plan.
2323    ReconcileReview {
2324        /// Provider whose review envelope is being reconciled.
2325        #[arg(long, value_enum)]
2326        provider: CiProviderArg,
2327
2328        /// Pull request number (GitHub).
2329        #[arg(long)]
2330        pr: Option<String>,
2331
2332        /// Merge request IID (GitLab).
2333        #[arg(long)]
2334        mr: Option<String>,
2335
2336        /// Path to a review-github or review-gitlab JSON envelope.
2337        #[arg(long)]
2338        envelope: PathBuf,
2339
2340        /// GitHub repository in owner/name form. Defaults to GH_REPO or GITHUB_REPOSITORY.
2341        #[arg(long)]
2342        repo: Option<String>,
2343
2344        /// GitLab project id or path. Defaults to CI_PROJECT_ID.
2345        #[arg(long = "project-id")]
2346        project_id: Option<String>,
2347
2348        /// Provider API base URL. Defaults to github.com or CI_API_V4_URL/gitlab.com.
2349        #[arg(long = "api-url")]
2350        api_url: Option<String>,
2351
2352        /// Compute the reconcile plan without posting resolution notes or resolving threads.
2353        #[arg(long)]
2354        dry_run: bool,
2355    },
2356}
2357
2358#[derive(Subcommand)]
2359enum RulePackCli {
2360    /// Scaffold a new rule pack file and wire it into the config
2361    Init {
2362        /// Pack name (default: the template name, or "team-policy")
2363        name: Option<String>,
2364
2365        /// Template: starter, ai-safe-repo, side-effect-free-domain, clean-architecture, next-app-router
2366        #[arg(long, default_value = "starter")]
2367        template: String,
2368
2369        /// Directory for the pack file, relative to the project root
2370        #[arg(long, default_value = "rule-packs")]
2371        dir: String,
2372
2373        /// Only write the pack file; do not modify the config
2374        #[arg(long)]
2375        no_config: bool,
2376    },
2377
2378    /// List configured rule packs and their rules
2379    List,
2380
2381    /// Evaluate a pack (or all configured packs) against this project and print matches
2382    Test {
2383        /// Path to a pack file to test in isolation (default: all configured packs)
2384        pack: Option<PathBuf>,
2385    },
2386
2387    /// Print the JSON Schema for rule pack files
2388    Schema,
2389}
2390
2391/// CLI mirror of [`fallow_engine::baseline::HealthBaselineMode`].
2392#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
2393pub enum BaselineModeArg {
2394    /// Match a saved health baseline per file and finding category.
2395    #[default]
2396    Count,
2397    /// Match a saved health baseline per function identity and finding
2398    /// category, so a hotspot that replaces another hotspot is reported.
2399    Identity,
2400}
2401
2402impl From<BaselineModeArg> for fallow_engine::baseline::HealthBaselineMode {
2403    fn from(value: BaselineModeArg) -> Self {
2404        match value {
2405            BaselineModeArg::Count => Self::Count,
2406            BaselineModeArg::Identity => Self::Identity,
2407        }
2408    }
2409}
2410
2411#[derive(Clone, Copy, Debug, clap::ValueEnum)]
2412enum CiProviderArg {
2413    Github,
2414    Gitlab,
2415}
2416
2417/// CLI mirror of [`fallow_config::TypeAwareRequire`].
2418#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2419enum TypeAwareRequireArg {
2420    /// Keep conservative findings and report semantic gaps.
2421    BestEffort,
2422    /// Fail the quality gate when a requested semantic query is incomplete.
2423    Complete,
2424}
2425
2426impl From<TypeAwareRequireArg> for fallow_config::TypeAwareRequire {
2427    fn from(value: TypeAwareRequireArg) -> Self {
2428        match value {
2429            TypeAwareRequireArg::BestEffort => Self::BestEffort,
2430            TypeAwareRequireArg::Complete => Self::Complete,
2431        }
2432    }
2433}
2434
2435/// Filter refactoring targets by effort level.
2436#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2437pub enum EffortFilter {
2438    Low,
2439    Medium,
2440    High,
2441}
2442
2443impl EffortFilter {
2444    /// Convert to the corresponding `EffortEstimate` for comparison.
2445    const fn to_estimate(self) -> fallow_output::EffortEstimate {
2446        match self {
2447            Self::Low => fallow_output::EffortEstimate::Low,
2448            Self::Medium => fallow_output::EffortEstimate::Medium,
2449            Self::High => fallow_output::EffortEstimate::High,
2450        }
2451    }
2452}
2453
2454/// CLI parser for the health severity gate.
2455#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2456pub enum HealthSeverityCli {
2457    Moderate,
2458    High,
2459    Critical,
2460}
2461
2462impl HealthSeverityCli {
2463    /// Convert to the typed health output severity.
2464    const fn to_health_severity(self) -> fallow_output::FindingSeverity {
2465        match self {
2466            Self::Moderate => fallow_output::FindingSeverity::Moderate,
2467            Self::High => fallow_output::FindingSeverity::High,
2468            Self::Critical => fallow_output::FindingSeverity::Critical,
2469        }
2470    }
2471}
2472
2473/// Privacy mode for author emails emitted by `--ownership`.
2474///
2475/// CLI mirror of [`fallow_config::EmailMode`]. Kept as a separate enum so
2476/// the help text controls rendering and we don't leak config-internal
2477/// schema details into clap.
2478#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2479pub enum EmailModeArg {
2480    /// Show full email addresses as recorded in git history.
2481    Raw,
2482    /// Show local-part only (default). Unwraps GitHub-style noreply prefixes.
2483    Handle,
2484    /// Show stable non-cryptographic pseudonyms (`xxh3:<hex>`).
2485    Anonymized,
2486    /// Legacy spelling for anonymized output.
2487    #[value(hide = true)]
2488    Hash,
2489}
2490
2491impl EmailModeArg {
2492    /// Convert to the equivalent config-level mode.
2493    const fn to_config(self) -> fallow_config::EmailMode {
2494        match self {
2495            Self::Raw => fallow_config::EmailMode::Raw,
2496            Self::Handle => fallow_config::EmailMode::Handle,
2497            Self::Anonymized => fallow_config::EmailMode::Anonymized,
2498            Self::Hash => fallow_config::EmailMode::Hash,
2499        }
2500    }
2501}
2502
2503/// CLI mirror of [`fallow_config::AuditGate`].
2504#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
2505pub enum AuditGateArg {
2506    /// Only findings introduced by the current changeset affect the verdict.
2507    NewOnly,
2508    /// All findings in changed files affect the verdict.
2509    All,
2510}
2511
2512impl From<AuditGateArg> for fallow_config::AuditGate {
2513    fn from(value: AuditGateArg) -> Self {
2514        match value {
2515            AuditGateArg::NewOnly => Self::NewOnly,
2516            AuditGateArg::All => Self::All,
2517        }
2518    }
2519}
2520
2521/// Parse `--min-occurrences` and reject values below 2. A single occurrence
2522/// is not a duplicate; silently clamping would diverge from the config-file
2523/// validator, which also rejects `< 2`.
2524fn parse_min_occurrences(s: &str) -> Result<usize, String> {
2525    let value: usize = s
2526        .parse()
2527        .map_err(|_| format!("`{s}` is not a non-negative integer"))?;
2528    if value < 2 {
2529        return Err(format!(
2530            "must be at least 2 (got {value}); a single occurrence isn't a duplicate"
2531        ));
2532    }
2533    Ok(value)
2534}
2535
2536/// Resolve an audit baseline path using CLI > config precedence.
2537///
2538/// Both sources resolve relative paths against the project root. This keeps
2539/// behavior consistent in CI scripts where `--root $REPO_ROOT` differs from
2540/// the process CWD.
2541fn resolve_audit_baseline_path(
2542    root: &std::path::Path,
2543    cli: Option<&std::path::Path>,
2544    config: Option<&str>,
2545) -> Option<PathBuf> {
2546    let path = cli.map(std::path::Path::to_path_buf).or_else(|| {
2547        config.map(|p| {
2548            let path = PathBuf::from(p);
2549            if path_util::is_absolute_path_any_platform(&path) {
2550                path
2551            } else {
2552                root.join(path)
2553            }
2554        })
2555    })?;
2556    if path_util::is_absolute_path_any_platform(&path) {
2557        Some(path)
2558    } else {
2559        Some(root.join(path))
2560    }
2561}
2562
2563fn emit_known_failure(
2564    message: &str,
2565    exit_code: u8,
2566    output: fallow_config::OutputFormat,
2567    reason: telemetry::FailureReason,
2568) -> ExitCode {
2569    telemetry::note_failure_reason(reason);
2570    emit_error(message, exit_code, output)
2571}
2572
2573fn emit_known_failure_with_style(
2574    message: &str,
2575    exit_code: u8,
2576    output: fallow_config::OutputFormat,
2577    json_style: json_style::JsonStyle,
2578    reason: telemetry::FailureReason,
2579) -> ExitCode {
2580    telemetry::note_failure_reason(reason);
2581    error::emit_error_with_style(message, exit_code, output, json_style)
2582}
2583
2584fn unsupported_security_global(cli: &Cli) -> Option<&'static str> {
2585    if cli.baseline.is_some() {
2586        Some("--baseline")
2587    } else if cli.save_baseline.is_some() {
2588        Some("--save-baseline")
2589    } else if cli.production {
2590        Some("--production")
2591    } else if cli.no_production {
2592        Some("--no-production")
2593    } else if cli.group_by.is_some() {
2594        Some("--group-by")
2595    } else if cli.performance {
2596        Some("--performance")
2597    } else if cli.explain_skipped {
2598        Some("--explain-skipped")
2599    } else if cli.fail_on_regression {
2600        Some("--fail-on-regression")
2601    } else if cli.regression_baseline.is_some() {
2602        Some("--regression-baseline")
2603    } else if cli.save_regression_baseline.is_some() {
2604        Some("--save-regression-baseline")
2605    } else if cli.dupes_mode.is_some() {
2606        Some("--dupes-mode")
2607    } else if cli.dupes_threshold.is_some() {
2608        Some("--dupes-threshold")
2609    } else if cli.dupes_min_tokens.is_some() {
2610        Some("--dupes-min-tokens")
2611    } else if cli.dupes_min_lines.is_some() {
2612        Some("--dupes-min-lines")
2613    } else if cli.dupes_min_occurrences.is_some() {
2614        Some("--dupes-min-occurrences")
2615    } else if cli.dupes_skip_local {
2616        Some("--dupes-skip-local")
2617    } else if cli.dupes_cross_language {
2618        Some("--dupes-cross-language")
2619    } else if cli.dupes_ignore_imports {
2620        Some("--dupes-ignore-imports")
2621    } else if cli.dupes_no_ignore_imports {
2622        Some("--dupes-no-ignore-imports")
2623    } else if cli.include_entry_exports {
2624        Some("--include-entry-exports")
2625    } else {
2626        None
2627    }
2628}
2629
2630struct DispatchContext<'a> {
2631    cli: &'a Cli,
2632    root: &'a std::path::Path,
2633    output: fallow_config::OutputFormat,
2634    quiet: bool,
2635    fail_on_issues: bool,
2636    json_style: json_style::JsonStyle,
2637    threads: usize,
2638    tolerance: regression::Tolerance,
2639    save_regression_file: Option<&'a std::path::PathBuf>,
2640    save_to_config: bool,
2641}
2642
2643impl DispatchContext<'_> {
2644    fn production_modes(
2645        &self,
2646        dead_code: bool,
2647        health: bool,
2648        dupes: bool,
2649    ) -> Result<ProductionModes, ExitCode> {
2650        resolve_production_modes(self.cli, self.root, self.output, dead_code, health, dupes)
2651    }
2652
2653    fn production_for(
2654        &self,
2655        analysis: fallow_config::ProductionAnalysis,
2656    ) -> Result<bool, ExitCode> {
2657        self.production_modes(false, false, false)
2658            .map(|modes| modes.for_analysis(analysis))
2659    }
2660
2661    fn regression_opts(&self, scoped: bool) -> regression::RegressionOpts<'_> {
2662        regression::RegressionOpts {
2663            fail_on_regression: self.cli.fail_on_regression,
2664            tolerance: self.tolerance,
2665            regression_baseline_file: self.cli.regression_baseline.as_deref(),
2666            save_target: if let Some(path) = self.save_regression_file {
2667                regression::SaveRegressionTarget::File(path)
2668            } else if self.save_to_config {
2669                regression::SaveRegressionTarget::Config
2670            } else {
2671                regression::SaveRegressionTarget::None
2672            },
2673            scoped,
2674            quiet: self.quiet,
2675            output: self.output,
2676        }
2677    }
2678}
2679
2680/// Test-only helper invoked when `FALLOW_TEST_SIGNAL_HELPER=1` is set.
2681/// Spawns `sleep 30` via the `ScopedChild` registry so the child is
2682/// tracked by the signal handler, prints the child PID to stdout, then
2683/// busy-waits so a SIGINT/SIGTERM delivered to the parent fires the
2684/// signal handler (which kills the child and exits 128+signum).
2685///
2686/// When `FALLOW_TEST_SIGNAL_HELPER_GRACEFUL=1` is also set, graceful
2687/// mode is activated BEFORE spawning the child. In graceful mode the
2688/// signal handler kills the child (proving drain runs unconditionally)
2689/// but does NOT call `std::process::exit`, so the helper itself sees
2690/// `wait_with_output` return and exits 0. This is the path the
2691/// integration test asserts: graceful drain + clean exit. Lives in
2692/// `main.rs` (not tests/) because clap is already parsed below and we
2693/// need to intercept before that.
2694#[cfg(unix)]
2695fn signal_test_helper() -> ExitCode {
2696    use std::io::Write as _;
2697    use std::process::Command;
2698
2699    if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER_GRACEFUL").is_some() {
2700        signal::set_graceful_mode();
2701    }
2702
2703    let mut command = Command::new("sleep");
2704    command.arg("30");
2705    let child = match signal::ScopedChild::spawn(&mut command) {
2706        Ok(c) => c,
2707        Err(err) => {
2708            let _ = writeln!(std::io::stderr(), "spawn sleep failed: {err}");
2709            return ExitCode::from(2);
2710        }
2711    };
2712    let pid = child.id();
2713    let stdout = std::io::stdout();
2714    let mut lock = stdout.lock();
2715    let _ = writeln!(lock, "{pid}");
2716    let _ = lock.flush();
2717    drop(lock);
2718    let _ = child.wait_with_output();
2719    if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER_GRACEFUL").is_some() {
2720        return ExitCode::SUCCESS;
2721    }
2722    std::thread::sleep(std::time::Duration::from_secs(5));
2723    ExitCode::SUCCESS
2724}
2725
2726#[cfg(not(unix))]
2727fn signal_test_helper() -> ExitCode {
2728    ExitCode::from(2)
2729}
2730
2731fn install_spawn_hooks() {
2732    fallow_engine::churn::set_spawn_hook(signal::scoped_child::output);
2733    fallow_engine::changed_files::set_spawn_hook(signal::scoped_child::output);
2734}
2735
2736fn install_signal_handlers() {
2737    if let Err(err) = signal::install_handlers() {
2738        use std::io::Write as _;
2739        let stderr = std::io::stderr();
2740        let mut lock = stderr.lock();
2741        let _ = writeln!(lock, "fallow: failed to install signal handlers: {err}");
2742    }
2743}
2744
2745/// Open `path` (creating parent dirs, truncating) and redirect report output
2746/// there via the ambient sink, forcing color off so the file carries no ANSI
2747/// codes even when attached to a TTY. Returns the error exit code if the file
2748/// cannot be created. Backs `--output-file`.
2749fn redirect_report_to_file(
2750    path: &std::path::Path,
2751    output: fallow_config::OutputFormat,
2752) -> Result<(), ExitCode> {
2753    if let Some(parent) = path.parent()
2754        && !parent.as_os_str().is_empty()
2755        && let Err(e) = std::fs::create_dir_all(parent)
2756    {
2757        return Err(emit_error(
2758            &format!(
2759                "failed to create {} for --output-file: {e}",
2760                parent.display()
2761            ),
2762            2,
2763            output,
2764        ));
2765    }
2766    match std::fs::File::create(path) {
2767        Ok(file) => {
2768            report::sink::set_file_sink(file);
2769            colored::control::set_override(false);
2770            Ok(())
2771        }
2772        Err(e) => Err(emit_error(
2773            &format!("failed to open {} for --output-file: {e}", path.display()),
2774            2,
2775            output,
2776        )),
2777    }
2778}
2779
2780/// Flush the report file after rendering and print the stderr confirmation
2781/// (suppressed by `--quiet`). Returns the error exit code on a write failure.
2782fn finalize_report_file(
2783    path: &std::path::Path,
2784    quiet: bool,
2785    output: fallow_config::OutputFormat,
2786) -> Result<(), ExitCode> {
2787    if let Err(e) = report::sink::flush() {
2788        return Err(emit_error(
2789            &format!("failed to write {}: {e}", path.display()),
2790            2,
2791            output,
2792        ));
2793    }
2794    // Suppress the confirmation when nothing was rendered to the file (a command
2795    // that errored before producing output sends its error to stdout, not the
2796    // file), so we never claim "Report written" over an empty file.
2797    if !quiet && report::sink::wrote() {
2798        eprintln!("Report written to {}", path.display());
2799    }
2800    Ok(())
2801}
2802
2803/// Run the full fallow CLI: parse argv, dispatch the selected command, and
2804/// return the process exit code. This is the crate's single entry point; the
2805/// `fallow` binary and the multicall `fallow-multicall` binary both delegate
2806/// here so there is exactly one clap tree and one dispatch path.
2807pub fn run() -> ExitCode {
2808    install_signal_handlers();
2809    install_spawn_hooks();
2810
2811    if std::env::var_os("FALLOW_TEST_SIGNAL_HELPER").is_some() {
2812        return signal_test_helper();
2813    }
2814
2815    let (mut cli, fmt) = match parse_cli_args() {
2816        Ok(parsed) => parsed,
2817        Err(code) => return code,
2818    };
2819    if cli.pretty && !fmt.payload_is_json {
2820        eprintln!(
2821            "Error: --pretty requires JSON output. Use --format json --pretty, or remove --pretty."
2822        );
2823        return ExitCode::from(2);
2824    }
2825
2826    if let Some(code) = run_schema_command_if_requested(&cli, fmt.json_style) {
2827        return code;
2828    }
2829
2830    if let Some(code) = run_telemetry_command_if_requested(&mut cli, fmt.output, fmt.json_style) {
2831        return code;
2832    }
2833    if is_impact_statusline(&cli) {
2834        let (root, _) = match validate_inputs(&cli, fmt.output, fmt.json_style) {
2835            Ok(validated) => validated,
2836            Err(code) => return code,
2837        };
2838        return cli_impact::render_impact_statusline(&root);
2839    }
2840    let telemetry_run = start_telemetry_run(&cli, &fmt);
2841
2842    let (root, threads) = match validate_inputs(&cli, fmt.output, fmt.json_style) {
2843        Ok(v) => v,
2844        Err(code) => {
2845            return record_run_epilogue(telemetry_run, code, None, cli.parent_run.as_deref());
2846        }
2847    };
2848
2849    let FormatConfig {
2850        output,
2851        payload_is_json: _,
2852        quiet,
2853        fail_on_issues,
2854        json_style,
2855    } = fmt;
2856
2857    let tolerance =
2858        match run_pre_dispatch_checks(&cli, &root, output, json_style, quiet, telemetry_run) {
2859            Ok(tolerance) => tolerance,
2860            Err(code) => return code,
2861        };
2862
2863    let (save_regression_file, save_to_config) = regression_save_targets(&cli);
2864
2865    let command = cli.command.take();
2866    let dispatch = DispatchContext {
2867        cli: &cli,
2868        root: &root,
2869        output,
2870        quiet,
2871        fail_on_issues,
2872        json_style,
2873        threads,
2874        tolerance,
2875        save_regression_file: save_regression_file.as_ref(),
2876        save_to_config,
2877    };
2878    let exit_code = match dispatch_and_finalize(&dispatch, command) {
2879        Ok(code) => code,
2880        Err(code) => return code,
2881    };
2882    record_run_epilogue(telemetry_run, exit_code, None, cli.parent_run.as_deref())
2883}
2884
2885/// Status bars refresh frequently, so their local read path bypasses telemetry,
2886/// update checks, notices, and every other command epilogue.
2887fn is_impact_statusline(cli: &Cli) -> bool {
2888    matches!(
2889        cli.command.as_ref(),
2890        Some(Command::Impact {
2891            subcommand: Some(ImpactCli::Statusline),
2892            all: false,
2893            ..
2894        })
2895    )
2896}
2897
2898/// Redirect the rendered report to `--output-file` (ambient sink), dispatch the
2899/// command, then flush+close the report file. Returns the dispatch exit code, or
2900/// `Err` carrying a redirect/finalize failure code for `main` to return directly.
2901fn dispatch_and_finalize(
2902    dispatch: &DispatchContext<'_>,
2903    command: Option<Command>,
2904) -> Result<ExitCode, ExitCode> {
2905    let cli = dispatch.cli;
2906    let output = dispatch.output;
2907    let quiet = dispatch.quiet;
2908
2909    // Set up the report-file sink before dispatch so rendering lands in the file;
2910    // progress and the confirmation stay on stderr.
2911    if let Some(path) = cli.output_file.as_deref()
2912        && let Err(code) = redirect_report_to_file(path, output)
2913    {
2914        return Err(code);
2915    }
2916
2917    let exit_code = if command.is_some() && cli_has_bare_coverage_input(cli) {
2918        emit_error(bare_coverage_subcommand_error_message(), 2, output)
2919    } else {
2920        match command {
2921            None => dispatch_bare_command(dispatch),
2922            Some(cmd) => dispatch_subcommand(cmd, dispatch),
2923        }
2924    };
2925
2926    if let Some(path) = cli.output_file.as_deref()
2927        && let Err(code) = finalize_report_file(path, quiet, output)
2928    {
2929        return Err(code);
2930    }
2931    Ok(exit_code)
2932}
2933
2934fn run_telemetry_command_if_requested(
2935    cli: &mut Cli,
2936    output: fallow_config::OutputFormat,
2937    json_style: json_style::JsonStyle,
2938) -> Option<ExitCode> {
2939    if matches!(cli.command, Some(Command::Telemetry { .. }))
2940        && let Some(Command::Telemetry { subcommand }) = cli.command.take()
2941    {
2942        return Some(telemetry::run(
2943            map_telemetry_subcommand(subcommand),
2944            output,
2945            json_style,
2946        ));
2947    }
2948    None
2949}
2950
2951fn run_schema_command_if_requested(
2952    cli: &Cli,
2953    json_style: json_style::JsonStyle,
2954) -> Option<ExitCode> {
2955    match cli.command {
2956        Some(Command::Schema) => Some(schema::run_schema(json_style)),
2957        Some(Command::ConfigSchema) => Some(init::run_config_schema(json_style)),
2958        Some(Command::PluginSchema) => Some(init::run_plugin_schema(json_style)),
2959        Some(Command::RulePackSchema) => Some(init::run_rule_pack_schema(json_style)),
2960        _ => None,
2961    }
2962}
2963
2964fn regression_save_targets(cli: &Cli) -> (Option<std::path::PathBuf>, bool) {
2965    let save_file = cli.save_regression_baseline.as_ref().and_then(|opt| {
2966        opt.as_ref()
2967            .filter(|path| !path.is_empty())
2968            .map(std::path::PathBuf::from)
2969    });
2970    let save_to_config = cli.save_regression_baseline.is_some() && save_file.is_none();
2971    (save_file, save_to_config)
2972}
2973
2974fn dispatch_bare_command(dispatch: &DispatchContext<'_>) -> ExitCode {
2975    let cli = dispatch.cli;
2976    let (run_check, run_dupes, run_health) = combined::resolve_analyses(&cli.only, &cli.skip);
2977    let production = match dispatch.production_modes(
2978        cli.production_dead_code,
2979        cli.production_health,
2980        cli.production_dupes,
2981    ) {
2982        Ok(production) => production,
2983        Err(code) => return code,
2984    };
2985    let coverage_inputs = match resolve_health_coverage_inputs(
2986        dispatch,
2987        cli.coverage.as_deref(),
2988        cli.coverage_root.as_deref(),
2989    ) {
2990        Ok(inputs) => inputs,
2991        Err(code) => return code,
2992    };
2993    run_bare_combined(
2994        dispatch,
2995        production,
2996        &coverage_inputs,
2997        BareAnalyses {
2998            run_check,
2999            run_dupes,
3000            run_health,
3001        },
3002    )
3003}
3004
3005/// Which analyses the bare `fallow` run executes (resolved from `--only`/`--skip`).
3006#[derive(Clone, Copy)]
3007struct BareAnalyses {
3008    run_check: bool,
3009    run_dupes: bool,
3010    run_health: bool,
3011}
3012
3013/// Build `CombinedOptions` for a bare `fallow` invocation and run the combined
3014/// pipeline.
3015fn run_bare_combined(
3016    dispatch: &DispatchContext<'_>,
3017    production: ProductionModes,
3018    coverage_inputs: &ResolvedHealthCoverageInputs,
3019    analyses: BareAnalyses,
3020) -> ExitCode {
3021    let cli = dispatch.cli;
3022    let (output, quiet, fail_on_issues) =
3023        (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
3024    combined::run_combined(&combined::CombinedOptions {
3025        root: dispatch.root,
3026        config_path: &cli.config,
3027        output,
3028        json_style: dispatch.json_style,
3029        no_cache: cli.no_cache,
3030        threads: dispatch.threads,
3031        quiet,
3032        allow_remote_extends: cli.allow_remote_extends,
3033        fail_on_issues,
3034        sarif_file: cli.sarif_file.as_deref(),
3035        changed_since: cli.changed_since.as_deref(),
3036        churn_file: cli.churn_file.as_deref(),
3037        baseline: cli.baseline.as_deref(),
3038        save_baseline: cli.save_baseline.as_deref(),
3039        production: cli.production,
3040        production_dead_code: Some(production.dead_code),
3041        production_health: Some(production.health),
3042        production_dupes: Some(production.dupes),
3043        workspace: cli.workspace.as_deref(),
3044        changed_workspaces: cli.changed_workspaces.as_deref(),
3045        group_by: cli.group_by,
3046        type_aware: cli.type_aware_override(),
3047        type_aware_projects: &cli.type_aware_project,
3048        type_aware_require: cli.type_aware_require.map(Into::into),
3049        explain: cli.explain,
3050        explain_skipped: cli.explain_skipped,
3051        performance: cli.performance,
3052        summary: cli.summary,
3053        run_check: analyses.run_check,
3054        run_dupes: analyses.run_dupes,
3055        run_health: analyses.run_health,
3056        dupes_mode: cli.dupes_mode,
3057        dupes_near: cli.dupes_near,
3058        dupes_threshold: cli.dupes_threshold,
3059        dupes_min_tokens: cli.dupes_min_tokens,
3060        dupes_min_lines: cli.dupes_min_lines,
3061        dupes_min_occurrences: cli.dupes_min_occurrences,
3062        dupes_skip_local: cli.dupes_skip_local,
3063        dupes_cross_language: cli.dupes_cross_language,
3064        dupes_ignore_imports: resolve_ignore_imports(
3065            cli.dupes_ignore_imports,
3066            cli.dupes_no_ignore_imports,
3067        ),
3068        score: cli.score || cli.trend,
3069        trend: cli.trend,
3070        save_snapshot: cli.save_snapshot.as_ref(),
3071        coverage: coverage_inputs.coverage.as_deref(),
3072        coverage_root: coverage_inputs.coverage_root.as_deref(),
3073        include_entry_exports: cli.include_entry_exports,
3074        regression_opts: dispatch.regression_opts(
3075            cli.changed_since.is_some()
3076                || cli.workspace.is_some()
3077                || cli.changed_workspaces.is_some(),
3078        ),
3079    })
3080}
3081
3082fn dispatch_subcommand(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3083    let cli = dispatch.cli;
3084    let root = dispatch.root;
3085    let output = dispatch.output;
3086    let quiet = dispatch.quiet;
3087    match command {
3088        check @ Command::Check { .. } => dispatch_check_command(check, dispatch),
3089        Command::Watch { no_clear } => dispatch_watch(dispatch, no_clear),
3090        Command::TypeAware { subcommand } => dispatch_type_aware_command(dispatch, subcommand),
3091        Command::Inspect {
3092            file,
3093            symbol,
3094            symbol_chain,
3095            churn,
3096        } => dispatch_inspect_command(dispatch, file, symbol, symbol_chain, churn),
3097        Command::Trace {
3098            symbol,
3099            callers,
3100            callees,
3101            depth,
3102        } => dispatch_trace_command(dispatch, symbol, callers, callees, depth),
3103        fix @ Command::Fix { .. } => dispatch_fix_command(&fix, dispatch),
3104        init @ Command::Init { .. } => dispatch_init_command(init, root, quiet),
3105        Command::Hooks { subcommand } => {
3106            run_hooks_command(root, subcommand, output, dispatch.json_style)
3107        }
3108        Command::Ci { subcommand } => {
3109            ci::run(map_ci_subcommand(subcommand), output, dispatch.json_style)
3110        }
3111        Command::ConfigSchema => init::run_config_schema(dispatch.json_style),
3112        Command::PluginSchema => init::run_plugin_schema(dispatch.json_style),
3113        Command::PluginCheck => plugin_check::run_plugin_check(root, output, dispatch.json_style),
3114        Command::RulePackSchema => init::run_rule_pack_schema(dispatch.json_style),
3115        Command::RulePack { subcommand } => dispatch_rule_pack_command(dispatch, subcommand),
3116        Command::Guard { files } => dispatch_guard_command(dispatch, &files),
3117        Command::CiTemplate { subcommand } => dispatch_ci_template_command(subcommand),
3118        Command::Config { path } => config::run_config_with_options(config::RunConfigInput {
3119            root,
3120            explicit_config: cli.config.as_deref(),
3121            path_only: path,
3122            output,
3123            quiet,
3124            json_style: dispatch.json_style,
3125            load_options: fallow_config::ConfigLoadOptions {
3126                allow_remote_extends: cli.allow_remote_extends,
3127            },
3128        }),
3129        Command::Recommend => onboarding::run_recommend(root, output, dispatch.json_style),
3130        list @ (Command::Workspaces | Command::List { .. }) => {
3131            dispatch_list_command(&list, dispatch)
3132        }
3133        dupes @ Command::Dupes { .. } => dispatch_dupes_command(dupes, dispatch),
3134        health @ Command::Health { .. } => dispatch_health_command(health, dispatch),
3135        Command::Flags { top } => dispatch_flags_command(dispatch, top),
3136        Command::Suppressions { file } => dispatch_suppressions_command(dispatch, &file),
3137        Command::Explain { issue_type } => {
3138            explain::run_explain(&issue_type.join(" "), output, dispatch.json_style)
3139        }
3140        audit @ Command::Audit { .. } => dispatch_audit_command(audit, dispatch),
3141        Command::AuditCache { subcommand } => dispatch_audit_cache_command(dispatch, &subcommand),
3142        Command::DecisionSurface { max_decisions } => {
3143            dispatch_decision_surface(dispatch, max_decisions)
3144        }
3145        Command::Impact {
3146            subcommand,
3147            all,
3148            sort,
3149            limit,
3150        } => dispatch_impact(
3151            root,
3152            quiet,
3153            output,
3154            dispatch.json_style,
3155            subcommand,
3156            ImpactCrossRepoOpts { all, sort, limit },
3157        ),
3158        security @ Command::Security { .. } => dispatch_security_command(security, dispatch),
3159        Command::Viz {
3160            output: viz_output,
3161            no_open,
3162            viz_format,
3163        } => dispatch_viz(dispatch, viz_output.as_deref(), no_open, viz_format),
3164        Command::Report { from } => {
3165            cli_report::run_report(&from, output, root, cli.config.as_deref())
3166        }
3167        Command::Schema => unreachable!("handled above"),
3168        migrate @ Command::Migrate { .. } => dispatch_migrate_command(migrate, root),
3169        Command::License { subcommand } => {
3170            dispatch_license_command(subcommand, output, dispatch.json_style)
3171        }
3172        Command::Telemetry { .. } => unreachable!("handled before root validation"),
3173        Command::Coverage { subcommand } => dispatch_coverage_command(dispatch, &subcommand),
3174        setup_hooks @ Command::SetupHooks { .. } => {
3175            dispatch_setup_hooks_command(&setup_hooks, dispatch)
3176        }
3177    }
3178}
3179
3180fn dispatch_type_aware_command(
3181    dispatch: &DispatchContext<'_>,
3182    subcommand: TypeAwareCli,
3183) -> ExitCode {
3184    match subcommand {
3185        TypeAwareCli::Status => {
3186            let status = fallow_api::type_aware_status(dispatch.root);
3187            match dispatch.output {
3188                fallow_config::OutputFormat::Json => {
3189                    let output = type_aware_status_output(dispatch.root, status);
3190                    match fallow_output::serialize_type_aware_status_json_output(
3191                        output,
3192                        crate::output_runtime::current_root_envelope_mode(),
3193                    ) {
3194                        Ok(value) => match dispatch.json_style.serialize(&value) {
3195                            Ok(json) => {
3196                                crate::report::sink::outln!("{json}");
3197                                ExitCode::SUCCESS
3198                            }
3199                            Err(error) => emit_error(
3200                                &format!("failed to serialize type-aware status: {error}"),
3201                                2,
3202                                dispatch.output,
3203                            ),
3204                        },
3205                        Err(error) => emit_error(
3206                            &format!("failed to build type-aware status: {error}"),
3207                            2,
3208                            dispatch.output,
3209                        ),
3210                    }
3211                }
3212                fallow_config::OutputFormat::Human => {
3213                    if status.available {
3214                        crate::report::sink::outln!(
3215                            "{}",
3216                            report::human_status_line(
3217                                report::HumanStatus::Ok,
3218                                format_args!(
3219                                    "Type-aware companion: available ({}, protocol {}, TypeScript {})",
3220                                    status.package_version.as_deref().unwrap_or("unknown"),
3221                                    status.protocol_version,
3222                                    status.backend_version.as_deref().unwrap_or("unknown"),
3223                                )
3224                            )
3225                        );
3226                    } else {
3227                        crate::report::sink::outln!(
3228                            "{}",
3229                            report::human_status_line(
3230                                report::HumanStatus::Inactive,
3231                                "Type-aware companion: unavailable"
3232                            )
3233                        );
3234                        if let Some(remediation) = status.remediation {
3235                            crate::report::sink::outln!(
3236                                "{}",
3237                                report::human_status_line(
3238                                    report::HumanStatus::Warning,
3239                                    format_args!("Action: {remediation}")
3240                                )
3241                            );
3242                        }
3243                    }
3244                    ExitCode::SUCCESS
3245                }
3246                _ => emit_error(
3247                    "type-aware status supports human and json output",
3248                    2,
3249                    dispatch.output,
3250                ),
3251            }
3252        }
3253    }
3254}
3255
3256fn type_aware_status_output(
3257    root: &Path,
3258    status: fallow_api::TypeAwareStatus,
3259) -> fallow_output::TypeAwareStatusOutput {
3260    let companion_path = status.companion_path.as_deref().map(|path| {
3261        if let Ok(relative) = path.strip_prefix(root)
3262            && !relative.as_os_str().is_empty()
3263        {
3264            relative.to_string_lossy().replace('\\', "/")
3265        } else {
3266            path.file_name()
3267                .unwrap_or(path.as_os_str())
3268                .to_string_lossy()
3269                .into_owned()
3270        }
3271    });
3272    let remediation = status.remediation.map(|message| {
3273        let without_root = message.replace(root.to_string_lossy().as_ref(), ".");
3274        status.companion_path.as_deref().map_or_else(
3275            || without_root.clone(),
3276            |path| {
3277                without_root.replace(
3278                    path.to_string_lossy().as_ref(),
3279                    companion_path.as_deref().unwrap_or("fallow-type-aware"),
3280                )
3281            },
3282        )
3283    });
3284    fallow_output::TypeAwareStatusOutput {
3285        schema_version: fallow_types::envelope::SchemaVersion(
3286            fallow_output::TYPE_AWARE_STATUS_SCHEMA_VERSION,
3287        ),
3288        version: fallow_types::envelope::ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
3289        available: status.available,
3290        discovery_source: status.discovery_source.map(str::to_string),
3291        companion_path,
3292        package_version: status.package_version,
3293        protocol_version: status.protocol_version,
3294        backend_family: status.backend_family,
3295        backend_version: status.backend_version,
3296        remediation,
3297    }
3298}
3299
3300/// Destructure the `Command::Check` arm and forward to `dispatch_check`.
3301fn dispatch_check_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3302    let filters = check_issue_filters(&command);
3303    let Command::Check {
3304        include_dupes,
3305        trace,
3306        trace_file,
3307        trace_dependency,
3308        impact_closure,
3309        symbol_impact,
3310        top,
3311        file,
3312        ..
3313    } = command
3314    else {
3315        unreachable!("check dispatcher only handles check commands");
3316    };
3317
3318    dispatch_check(
3319        dispatch,
3320        &CheckDispatchArgs {
3321            filters,
3322            trace_opts: TraceOptions {
3323                trace_export: trace,
3324                trace_file,
3325                trace_dependency,
3326                impact_closure,
3327                symbol_impact,
3328                performance: dispatch.cli.performance,
3329            },
3330            include_dupes,
3331            type_aware: dispatch.cli.type_aware_override(),
3332            type_aware_project: dispatch.cli.type_aware_project.clone(),
3333            type_aware_require: dispatch.cli.type_aware_require,
3334            top,
3335            file,
3336        },
3337    )
3338}
3339
3340/// Map the `Command::Check` filter flags onto `IssueFilters`. Reads the flags by
3341/// reference (all `Copy` bools) so the caller can still move the non-filter
3342/// fields out of the same `Command` value afterwards. Split into two halves to
3343/// keep each builder within the unit-size limit.
3344fn check_issue_filters(command: &Command) -> IssueFilters {
3345    check_issue_filters_framework(command, &check_issue_filters_core(command))
3346}
3347
3348/// First half of the `IssueFilters` mapping: core/general filter flags over a
3349/// `Default` base. The framework/catalog half layers on top via struct update.
3350fn check_issue_filters_core(command: &Command) -> IssueFilters {
3351    let Command::Check {
3352        unused_files,
3353        unused_exports,
3354        unused_deps,
3355        unused_types,
3356        private_type_leaks,
3357        unused_enum_members,
3358        unused_class_members,
3359        unresolved_imports,
3360        unlisted_deps,
3361        duplicate_exports,
3362        circular_deps,
3363        re_export_cycles,
3364        boundary_violations,
3365        policy_violations,
3366        stale_suppressions,
3367        ..
3368    } = command
3369    else {
3370        unreachable!("check filter builder only handles check commands");
3371    };
3372
3373    let mut filters = IssueFilters::default();
3374    for (flag, active) in [
3375        ("--unused-files", *unused_files),
3376        ("--unused-exports", *unused_exports),
3377        ("--unused-deps", *unused_deps),
3378        ("--unused-types", *unused_types),
3379        ("--private-type-leaks", *private_type_leaks),
3380        ("--unused-enum-members", *unused_enum_members),
3381        ("--unused-class-members", *unused_class_members),
3382        ("--unresolved-imports", *unresolved_imports),
3383        ("--unlisted-deps", *unlisted_deps),
3384        ("--duplicate-exports", *duplicate_exports),
3385        ("--circular-deps", *circular_deps),
3386        ("--re-export-cycles", *re_export_cycles),
3387        ("--boundary-violations", *boundary_violations),
3388        ("--policy-violations", *policy_violations),
3389        ("--stale-suppressions", *stale_suppressions),
3390    ] {
3391        enable_check_filter(&mut filters, flag, active);
3392    }
3393    filters
3394}
3395
3396/// Second half of the `IssueFilters` mapping: framework/component, store, svelte,
3397/// catalog, and dependency-override flags, layered onto the core `base`.
3398fn check_issue_filters_framework(command: &Command, base: &IssueFilters) -> IssueFilters {
3399    let Command::Check {
3400        unused_store_members,
3401        unprovided_injects,
3402        unrendered_components,
3403        unused_component_props,
3404        unused_component_emits,
3405        unused_component_inputs,
3406        unused_component_outputs,
3407        unused_svelte_events,
3408        unused_server_actions,
3409        unused_load_data_keys,
3410        unused_catalog_entries,
3411        empty_catalog_groups,
3412        unresolved_catalog_references,
3413        unused_dependency_overrides,
3414        misconfigured_dependency_overrides,
3415        ..
3416    } = command
3417    else {
3418        unreachable!("check filter builder only handles check commands");
3419    };
3420
3421    let mut filters = base.clone();
3422    for (flag, active) in [
3423        ("--unused-store-members", *unused_store_members),
3424        ("--unprovided-injects", *unprovided_injects),
3425        ("--unrendered-components", *unrendered_components),
3426        ("--unused-component-props", *unused_component_props),
3427        ("--unused-component-emits", *unused_component_emits),
3428        ("--unused-component-inputs", *unused_component_inputs),
3429        ("--unused-component-outputs", *unused_component_outputs),
3430        ("--unused-svelte-events", *unused_svelte_events),
3431        ("--unused-server-actions", *unused_server_actions),
3432        ("--unused-load-data-keys", *unused_load_data_keys),
3433        ("--unused-catalog-entries", *unused_catalog_entries),
3434        ("--empty-catalog-groups", *empty_catalog_groups),
3435        (
3436            "--unresolved-catalog-references",
3437            *unresolved_catalog_references,
3438        ),
3439        (
3440            "--unused-dependency-overrides",
3441            *unused_dependency_overrides,
3442        ),
3443        (
3444            "--misconfigured-dependency-overrides",
3445            *misconfigured_dependency_overrides,
3446        ),
3447    ] {
3448        enable_check_filter(&mut filters, flag, active);
3449    }
3450    filters
3451}
3452
3453fn enable_check_filter(filters: &mut IssueFilters, flag: &str, active: bool) {
3454    if active {
3455        assert!(
3456            filters.enable_cli_filter_flag(flag),
3457            "check command uses unregistered dead-code filter flag {flag}"
3458        );
3459    }
3460}
3461
3462fn dispatch_inspect_command(
3463    dispatch: &DispatchContext<'_>,
3464    file: Option<String>,
3465    symbol: Option<String>,
3466    symbol_chain: bool,
3467    churn: bool,
3468) -> ExitCode {
3469    let target = match (file, symbol) {
3470        (Some(file), None) => inspect::InspectTarget::File { file },
3471        (None, Some(symbol)) => match symbol.rsplit_once(':') {
3472            Some((file, export_name))
3473                if !file.trim().is_empty() && !export_name.trim().is_empty() =>
3474            {
3475                inspect::InspectTarget::Symbol {
3476                    file: file.to_string(),
3477                    export_name: export_name.to_string(),
3478                }
3479            }
3480            _ => {
3481                return emit_error(
3482                    "--symbol must be formatted as FILE:EXPORT",
3483                    2,
3484                    dispatch.output,
3485                );
3486            }
3487        },
3488        _ => {
3489            return emit_error(
3490                "inspect requires exactly one of --file or --symbol",
3491                2,
3492                dispatch.output,
3493            );
3494        }
3495    };
3496
3497    let churn_config = if churn {
3498        match load_config_for_analysis(
3499            dispatch.root,
3500            &dispatch.cli.config,
3501            ConfigLoadOptions {
3502                output: dispatch.output,
3503                no_cache: dispatch.cli.no_cache,
3504                threads: dispatch.threads,
3505                production_override: None,
3506                quiet: dispatch.quiet,
3507                allow_remote_extends: dispatch.cli.allow_remote_extends,
3508            },
3509            fallow_config::ProductionAnalysis::Health,
3510        ) {
3511            Ok(config) => Some(config),
3512            Err(code) => return code,
3513        }
3514    } else {
3515        None
3516    };
3517
3518    inspect::run_inspect(&inspect::InspectOptions {
3519        root: dispatch.root,
3520        config_path: dispatch.cli.config.as_ref(),
3521        output: dispatch.output,
3522        json_style: dispatch.json_style,
3523        no_cache: dispatch.cli.no_cache,
3524        no_production: dispatch.cli.no_production,
3525        max_file_size: dispatch.cli.max_file_size,
3526        threads: dispatch.threads,
3527        quiet: dispatch.quiet,
3528        production: dispatch.cli.production,
3529        workspace: dispatch.cli.workspace.as_ref(),
3530        target,
3531        churn_cache_dir: churn_config
3532            .as_ref()
3533            .map(|config| config.cache_dir.as_path()),
3534        symbol_chain,
3535        type_aware: dispatch.cli.type_aware_override(),
3536        type_aware_projects: &dispatch.cli.type_aware_project,
3537        type_aware_require: dispatch.cli.type_aware_require.map(Into::into),
3538    })
3539}
3540
3541fn dispatch_trace_command(
3542    dispatch: &DispatchContext<'_>,
3543    symbol: String,
3544    callers: bool,
3545    callees: bool,
3546    depth: Option<u32>,
3547) -> ExitCode {
3548    trace_chain::run_trace(&trace_chain::TraceChainOptions {
3549        root: dispatch.root,
3550        config_path: &dispatch.cli.config,
3551        output: dispatch.output,
3552        json_style: dispatch.json_style,
3553        no_cache: dispatch.cli.no_cache,
3554        threads: dispatch.threads,
3555        quiet: dispatch.quiet,
3556        allow_remote_extends: dispatch.cli.allow_remote_extends,
3557        target: symbol,
3558        callers,
3559        callees,
3560        depth: depth.unwrap_or(fallow_types::trace_chain::DEFAULT_TRACE_DEPTH),
3561    })
3562}
3563
3564fn dispatch_security_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3565    let Command::Security {
3566        subcommand,
3567        runtime_coverage,
3568        min_invocations_hot,
3569        file,
3570        gate,
3571        surface,
3572    } = command
3573    else {
3574        unreachable!("security dispatcher only handles security commands");
3575    };
3576
3577    let gate = gate.map(security::SecurityGateArg::into_mode);
3578    let cli = dispatch.cli;
3579    let (output, _quiet, fail_on_issues) =
3580        (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
3581    let derived_flags = SecurityDerivedFlagState {
3582        output,
3583        json_style: dispatch.json_style,
3584        ci: cli.ci,
3585        fail_on_issues,
3586        sarif_file: cli.sarif_file.as_deref(),
3587        summary: cli.summary,
3588        explain: cli.explain,
3589        runtime_coverage: runtime_coverage.as_deref(),
3590        min_invocations_hot,
3591        file: file.as_slice(),
3592        gate,
3593        surface,
3594    };
3595    if let Some(code) = try_run_security_survivors(subcommand.as_ref(), &derived_flags) {
3596        return code;
3597    }
3598
3599    let scoped_files = scoped_security_files(&file, subcommand.as_ref());
3600    run_security_blind_spots_or_default(
3601        dispatch,
3602        &SecurityRunInputs {
3603            scoped_files: &scoped_files,
3604            subcommand: &subcommand,
3605            runtime_coverage: runtime_coverage.as_deref(),
3606            min_invocations_hot,
3607            gate,
3608            surface,
3609        },
3610        &derived_flags,
3611    )
3612}
3613
3614/// Inputs threaded from the security dispatcher into the run step. Borrows the
3615/// scoped file list and subcommand so they outlive the `SecurityOptions`.
3616struct SecurityRunInputs<'a> {
3617    scoped_files: &'a [PathBuf],
3618    subcommand: &'a Option<SecuritySubcommand>,
3619    runtime_coverage: Option<&'a Path>,
3620    min_invocations_hot: u64,
3621    gate: Option<security::SecurityGateMode>,
3622    surface: bool,
3623}
3624
3625/// Build `SecurityOptions` and run either the blind-spots or default analysis.
3626fn run_security_blind_spots_or_default(
3627    dispatch: &DispatchContext<'_>,
3628    inputs: &SecurityRunInputs<'_>,
3629    derived_flags: &SecurityDerivedFlagState<'_>,
3630) -> ExitCode {
3631    let cli = dispatch.cli;
3632    let (output, quiet, fail_on_issues) =
3633        (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
3634    let opts = security::SecurityOptions {
3635        root: dispatch.root,
3636        config_path: &cli.config,
3637        output,
3638        json_style: dispatch.json_style,
3639        no_cache: cli.no_cache,
3640        threads: dispatch.threads,
3641        quiet,
3642        allow_remote_extends: cli.allow_remote_extends,
3643        fail_on_issues,
3644        sarif_file: cli.sarif_file.as_deref(),
3645        summary: cli.summary,
3646        changed_since: cli.changed_since.as_deref(),
3647        use_shared_diff_index: true,
3648        workspace: cli.workspace.as_deref(),
3649        changed_workspaces: cli.changed_workspaces.as_deref(),
3650        file: inputs.scoped_files,
3651        surface: inputs.surface,
3652        gate: inputs.gate,
3653        runtime_coverage: inputs.runtime_coverage,
3654        min_invocations_hot: inputs.min_invocations_hot,
3655        explain: cli.explain,
3656    };
3657    if matches!(
3658        inputs.subcommand,
3659        Some(SecuritySubcommand::BlindSpots { .. })
3660    ) {
3661        if let Some(code) = validate_security_blind_spots_flags(derived_flags) {
3662            return code;
3663        }
3664        security::run_blind_spots(&opts)
3665    } else {
3666        security::run(&opts)
3667    }
3668}
3669
3670/// Handle `fallow security survivors` as an early return. Returns `Some(code)`
3671/// when the subcommand is `survivors` (validated then run); `None` otherwise.
3672fn try_run_security_survivors(
3673    subcommand: Option<&SecuritySubcommand>,
3674    flags: &SecurityDerivedFlagState<'_>,
3675) -> Option<ExitCode> {
3676    let Some(SecuritySubcommand::Survivors {
3677        candidates,
3678        verdicts,
3679        require_verdict_for_each_candidate,
3680    }) = subcommand
3681    else {
3682        return None;
3683    };
3684    if let Some(code) = validate_security_survivors_flags(flags) {
3685        return Some(code);
3686    }
3687    Some(security::run_survivors(
3688        &security::SecuritySurvivorsOptions {
3689            output: flags.output,
3690            json_style: flags.json_style,
3691            candidates,
3692            verdicts,
3693            require_verdict_for_each_candidate: *require_verdict_for_each_candidate,
3694        },
3695    ))
3696}
3697
3698/// Build the scoped file list, folding in `blind-spots` extra `--file` values.
3699fn scoped_security_files(
3700    file: &[PathBuf],
3701    subcommand: Option<&SecuritySubcommand>,
3702) -> Vec<PathBuf> {
3703    let mut scoped_files = file.to_vec();
3704    if let Some(SecuritySubcommand::BlindSpots {
3705        file: blind_spot_files,
3706    }) = subcommand
3707    {
3708        scoped_files.extend(blind_spot_files.iter().cloned());
3709    }
3710    scoped_files
3711}
3712
3713struct SecurityDerivedFlagState<'a> {
3714    output: fallow_config::OutputFormat,
3715    json_style: json_style::JsonStyle,
3716    ci: bool,
3717    fail_on_issues: bool,
3718    sarif_file: Option<&'a Path>,
3719    summary: bool,
3720    explain: bool,
3721    runtime_coverage: Option<&'a Path>,
3722    min_invocations_hot: u64,
3723    file: &'a [PathBuf],
3724    gate: Option<security::SecurityGateMode>,
3725    surface: bool,
3726}
3727
3728fn validate_security_survivors_flags(flags: &SecurityDerivedFlagState<'_>) -> Option<ExitCode> {
3729    let flag = if flags.ci {
3730        Some("--ci")
3731    } else if flags.fail_on_issues {
3732        Some("--fail-on-issues")
3733    } else if flags.sarif_file.is_some() {
3734        Some("--sarif-file")
3735    } else if flags.summary {
3736        Some("--summary")
3737    } else if flags.explain {
3738        Some("--explain")
3739    } else if flags.runtime_coverage.is_some() {
3740        Some("--runtime-coverage")
3741    } else if flags.min_invocations_hot != DEFAULT_MIN_INVOCATIONS_HOT {
3742        Some("--min-invocations-hot")
3743    } else if !flags.file.is_empty() {
3744        Some("--file")
3745    } else if flags.gate.is_some() {
3746        Some("--gate")
3747    } else if flags.surface {
3748        Some("--surface")
3749    } else {
3750        None
3751    }?;
3752    Some(emit_error(
3753        &format!("{flag} is not valid with `fallow security survivors`."),
3754        2,
3755        flags.output,
3756    ))
3757}
3758
3759fn validate_security_blind_spots_flags(flags: &SecurityDerivedFlagState<'_>) -> Option<ExitCode> {
3760    let flag = if flags.ci {
3761        Some("--ci")
3762    } else if flags.fail_on_issues {
3763        Some("--fail-on-issues")
3764    } else if flags.sarif_file.is_some() {
3765        Some("--sarif-file")
3766    } else if flags.summary {
3767        Some("--summary")
3768    } else if flags.explain {
3769        Some("--explain")
3770    } else if flags.runtime_coverage.is_some() {
3771        Some("--runtime-coverage")
3772    } else if flags.min_invocations_hot != DEFAULT_MIN_INVOCATIONS_HOT {
3773        Some("--min-invocations-hot")
3774    } else if flags.gate.is_some() {
3775        Some("--gate")
3776    } else if flags.surface {
3777        Some("--surface")
3778    } else {
3779        None
3780    }?;
3781    Some(emit_error(
3782        &format!("{flag} is not valid with `fallow security blind-spots`."),
3783        2,
3784        flags.output,
3785    ))
3786}
3787
3788fn dispatch_dupes_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3789    let Command::Dupes {
3790        mode,
3791        near,
3792        min_tokens,
3793        min_lines,
3794        min_occurrences,
3795        threshold,
3796        skip_local,
3797        cross_language,
3798        ignore_imports,
3799        no_ignore_imports,
3800        top,
3801        trace,
3802    } = command
3803    else {
3804        unreachable!("dupes dispatcher only handles dupes commands");
3805    };
3806
3807    dispatch_dupes(
3808        dispatch,
3809        &DupesDispatchArgs {
3810            mode,
3811            near,
3812            min_tokens,
3813            min_lines,
3814            min_occurrences,
3815            threshold,
3816            skip_local,
3817            cross_language,
3818            ignore_imports,
3819            no_ignore_imports,
3820            top,
3821            trace,
3822        },
3823    )
3824}
3825
3826fn dispatch_init_command(command: Command, root: &Path, quiet: bool) -> ExitCode {
3827    let Command::Init {
3828        toml,
3829        agents,
3830        hooks,
3831        branch,
3832        decline,
3833    } = command
3834    else {
3835        unreachable!("init dispatcher only handles init commands");
3836    };
3837
3838    init::run_init(&init::InitOptions {
3839        root,
3840        use_toml: toml,
3841        agents,
3842        hooks,
3843        branch: branch.as_deref(),
3844        decline,
3845        quiet,
3846    })
3847}
3848
3849fn dispatch_fix_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3850    let Command::Fix {
3851        dry_run,
3852        yes,
3853        no_create_config,
3854    } = command
3855    else {
3856        unreachable!("fix dispatcher only handles fix commands");
3857    };
3858
3859    dispatch_fix(
3860        dispatch,
3861        FixDispatchArgs {
3862            dry_run: *dry_run,
3863            yes: *yes,
3864            no_create_config: *no_create_config,
3865        },
3866    )
3867}
3868
3869fn dispatch_list_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3870    match command {
3871        Command::Workspaces => dispatch_list(dispatch, ListDispatchArgs::workspaces()),
3872        Command::List {
3873            entry_points,
3874            files,
3875            plugins,
3876            boundaries,
3877            workspaces,
3878        } => dispatch_list(
3879            dispatch,
3880            ListDispatchArgs {
3881                entry_points: *entry_points,
3882                files: *files,
3883                plugins: *plugins,
3884                boundaries: *boundaries,
3885                workspaces: *workspaces,
3886            },
3887        ),
3888        _ => unreachable!("list dispatcher only handles list commands"),
3889    }
3890}
3891
3892fn dispatch_migrate_command(command: Command, root: &Path) -> ExitCode {
3893    let Command::Migrate {
3894        toml,
3895        jsonc,
3896        dry_run,
3897        from,
3898    } = command
3899    else {
3900        unreachable!("migrate dispatcher only handles migrate commands");
3901    };
3902
3903    migrate::run_migrate(root, toml, jsonc, dry_run, from.as_deref())
3904}
3905
3906fn dispatch_license_command(
3907    subcommand: LicenseCli,
3908    output: fallow_config::OutputFormat,
3909    json_style: json_style::JsonStyle,
3910) -> ExitCode {
3911    license::run(&map_license_subcommand(subcommand), output, json_style)
3912}
3913
3914fn dispatch_ci_template_command(subcommand: CiTemplateCli) -> ExitCode {
3915    match subcommand {
3916        CiTemplateCli::Gitlab { vendor, force } => {
3917            ci_template::run_gitlab_template(&ci_template::GitlabTemplateOptions {
3918                vendor_dir: vendor,
3919                force,
3920            })
3921        }
3922    }
3923}
3924
3925fn dispatch_coverage_command(dispatch: &DispatchContext<'_>, subcommand: &CoverageCli) -> ExitCode {
3926    let cli = dispatch.cli;
3927    coverage::run(
3928        map_coverage_subcommand(subcommand, cli.explain),
3929        &coverage::RunContext {
3930            root: dispatch.root,
3931            config_path: &cli.config,
3932            output: dispatch.output,
3933            json_style: dispatch.json_style,
3934            quiet: dispatch.quiet,
3935            no_cache: cli.no_cache,
3936            threads: dispatch.threads,
3937            explain: cli.explain,
3938            allow_remote_extends: cli.allow_remote_extends,
3939        },
3940    )
3941}
3942
3943fn dispatch_health_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
3944    let Command::Health {
3945        max_cyclomatic,
3946        max_cognitive,
3947        max_crap,
3948        top,
3949        sort,
3950        complexity,
3951        complexity_breakdown,
3952        file_scores,
3953        coverage_gaps,
3954        hotspots,
3955        ownership,
3956        ownership_emails,
3957        targets,
3958        type_coupling,
3959        css,
3960        effort,
3961        score,
3962        min_score,
3963        min_severity,
3964        report_only,
3965        since,
3966        min_commits,
3967        save_snapshot,
3968        trend,
3969        coverage,
3970        coverage_root,
3971        runtime_coverage,
3972        min_invocations_hot,
3973        min_observation_volume,
3974        low_traffic_threshold,
3975    } = command
3976    else {
3977        unreachable!("health dispatcher only handles health commands");
3978    };
3979
3980    let ownership = ownership || ownership_emails.is_some();
3981    let hotspots = hotspots || ownership;
3982    let args = HealthDispatchArgs {
3983        max_cyclomatic,
3984        max_cognitive,
3985        max_crap,
3986        top,
3987        sort,
3988        complexity,
3989        complexity_breakdown,
3990        file_scores,
3991        coverage_gaps,
3992        hotspots,
3993        ownership,
3994        ownership_emails: ownership_emails.map(EmailModeArg::to_config),
3995        targets,
3996        type_coupling,
3997        css,
3998        effort,
3999        score,
4000        min_score,
4001        min_severity: min_severity.map(HealthSeverityCli::to_health_severity),
4002        report_only,
4003        since: since.as_deref(),
4004        min_commits,
4005        save_snapshot: save_snapshot.as_ref(),
4006        trend,
4007        coverage: coverage.as_deref(),
4008        coverage_root: coverage_root.as_deref(),
4009        runtime_coverage: runtime_coverage.as_deref(),
4010        min_invocations_hot,
4011        min_observation_volume,
4012        low_traffic_threshold,
4013    };
4014    dispatch_health(dispatch, &args)
4015}
4016
4017fn dispatch_setup_hooks_command(command: &Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4018    let Command::SetupHooks {
4019        agent,
4020        dry_run,
4021        force,
4022        user,
4023        gitignore_claude,
4024        uninstall,
4025    } = command
4026    else {
4027        unreachable!("setup-hooks dispatcher only handles setup-hooks commands");
4028    };
4029
4030    setup_hooks::run_setup_hooks(&setup_hooks::SetupHooksOptions {
4031        root: dispatch.root,
4032        agent: *agent,
4033        dry_run: *dry_run,
4034        force: *force,
4035        user: *user,
4036        gitignore_claude: *gitignore_claude,
4037        uninstall: *uninstall,
4038    })
4039}
4040
4041fn dispatch_audit_command(command: Command, dispatch: &DispatchContext<'_>) -> ExitCode {
4042    let Command::Audit {
4043        production_dead_code,
4044        production_health,
4045        production_dupes,
4046        dead_code_baseline,
4047        health_baseline,
4048        dupes_baseline,
4049        max_crap,
4050        coverage,
4051        coverage_root,
4052        no_css,
4053        css_deep,
4054        no_css_deep,
4055        gate,
4056        runtime_coverage,
4057        min_invocations_hot,
4058        gate_marker,
4059        brief,
4060        max_decisions,
4061        walkthrough_guide,
4062        walkthrough_file,
4063        walkthrough,
4064        mark_viewed,
4065        show_cleared,
4066        show_deprioritized,
4067    } = command
4068    else {
4069        unreachable!("audit dispatcher only handles audit commands");
4070    };
4071
4072    // The walkthrough flags imply the brief path (the guide digest + the
4073    // graph-snapshot pin are brief-path data).
4074    let brief = brief || walkthrough_guide || walkthrough || walkthrough_file.is_some();
4075
4076    dispatch_audit(
4077        dispatch,
4078        &AuditDispatchArgs {
4079            production_dead_code,
4080            production_health,
4081            production_dupes,
4082            dead_code_baseline,
4083            health_baseline,
4084            dupes_baseline,
4085            max_crap,
4086            coverage,
4087            coverage_root,
4088            no_css,
4089            css_deep,
4090            no_css_deep,
4091            gate,
4092            runtime_coverage,
4093            min_invocations_hot,
4094            gate_marker,
4095            brief,
4096            max_decisions,
4097            walkthrough_guide,
4098            walkthrough_file,
4099            walkthrough,
4100            mark_viewed,
4101            show_cleared,
4102            show_deprioritized,
4103        },
4104    )
4105}
4106
4107fn dispatch_audit_cache_command(
4108    dispatch: &DispatchContext<'_>,
4109    subcommand: &AuditCacheCli,
4110) -> ExitCode {
4111    match subcommand {
4112        AuditCacheCli::Remove { dry_run, yes } => {
4113            if !*dry_run && !*yes && !std::io::stdin().is_terminal() {
4114                return emit_error(
4115                    "audit-cache remove requires --yes (or --force) in non-interactive environments. Use --dry-run to preview removal first, then pass --yes to confirm.",
4116                    2,
4117                    dispatch.output,
4118                );
4119            }
4120            match base_worktree::remove_reusable_audit_caches(dispatch.root, *dry_run) {
4121                Ok(report) => {
4122                    let action = if *dry_run { "would remove" } else { "removed" };
4123                    if matches!(dispatch.output, fallow_config::OutputFormat::Json) {
4124                        let value = serde_json::json!({
4125                            "kind": "audit-cache-remove",
4126                            "schema_version": 1,
4127                            "command": "audit-cache remove",
4128                            "root": dispatch.root,
4129                            "dry_run": report.dry_run,
4130                            "found": report.found,
4131                            "would_remove": report.found.saturating_sub(report.skipped),
4132                            "removed": report.removed,
4133                            "skipped": report.skipped,
4134                            "complete": report.skipped == 0,
4135                        });
4136                        let output_code = report::emit_report_json(
4137                            &value,
4138                            "audit cache removal",
4139                            dispatch.json_style,
4140                        );
4141                        if output_code != ExitCode::SUCCESS {
4142                            return output_code;
4143                        }
4144                    } else if !dispatch.quiet {
4145                        println!(
4146                            "audit cache: {action} {}, skipped {} for {}",
4147                            if *dry_run {
4148                                report.found.saturating_sub(report.skipped)
4149                            } else {
4150                                report.removed
4151                            },
4152                            report.skipped,
4153                            dispatch.root.display(),
4154                        );
4155                    }
4156                    if report.skipped == 0 {
4157                        ExitCode::SUCCESS
4158                    } else {
4159                        ExitCode::from(2)
4160                    }
4161                }
4162                Err(error) => emit_error(
4163                    &format!(
4164                        "failed to remove audit caches for {}: {error}",
4165                        dispatch.root.display()
4166                    ),
4167                    2,
4168                    dispatch.output,
4169                ),
4170            }
4171        }
4172    }
4173}
4174
4175fn dispatch_flags_command(dispatch: &DispatchContext<'_>, top: Option<usize>) -> ExitCode {
4176    let cli = dispatch.cli;
4177    let root = dispatch.root;
4178    let output = dispatch.output;
4179    let quiet = dispatch.quiet;
4180    let threads = dispatch.threads;
4181    let production = match resolve_production_modes(cli, root, output, false, false, false) {
4182        Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::DeadCode),
4183        Err(code) => return code,
4184    };
4185    flags::run_flags(&flags::FlagsOptions {
4186        root,
4187        config_path: &cli.config,
4188        output,
4189        json_style: dispatch.json_style,
4190        no_cache: cli.no_cache,
4191        threads,
4192        quiet,
4193        allow_remote_extends: cli.allow_remote_extends,
4194        production,
4195        workspace: cli.workspace.as_deref(),
4196        changed_workspaces: cli.changed_workspaces.as_deref(),
4197        changed_since: cli.changed_since.as_deref(),
4198        explain: cli.explain,
4199        top,
4200    })
4201}
4202
4203fn dispatch_suppressions_command(
4204    dispatch: &DispatchContext<'_>,
4205    file: &[std::path::PathBuf],
4206) -> ExitCode {
4207    let cli = dispatch.cli;
4208    let root = dispatch.root;
4209    let output = dispatch.output;
4210    let production = match resolve_production_modes(cli, root, output, false, false, false) {
4211        Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::DeadCode),
4212        Err(code) => return code,
4213    };
4214    suppressions::run_suppressions(&suppressions::SuppressionsOptions {
4215        root,
4216        config_path: &cli.config,
4217        output,
4218        json_style: dispatch.json_style,
4219        no_cache: cli.no_cache,
4220        threads: dispatch.threads,
4221        quiet: dispatch.quiet,
4222        allow_remote_extends: cli.allow_remote_extends,
4223        production,
4224        workspace: cli.workspace.as_deref(),
4225        changed_workspaces: cli.changed_workspaces.as_deref(),
4226        changed_since: cli.changed_since.as_deref(),
4227        file,
4228    })
4229}
4230
4231fn dispatch_guard_command(dispatch: &DispatchContext<'_>, files: &[String]) -> ExitCode {
4232    guard::run_guard(&guard::GuardOptions {
4233        root: dispatch.root,
4234        config_path: &dispatch.cli.config,
4235        output: dispatch.output,
4236        json_style: dispatch.json_style,
4237        quiet: dispatch.quiet,
4238        allow_remote_extends: dispatch.cli.allow_remote_extends,
4239        files,
4240    })
4241}
4242
4243fn dispatch_rule_pack_command(dispatch: &DispatchContext<'_>, subcommand: RulePackCli) -> ExitCode {
4244    let ctx = rule_pack::RulePackContext {
4245        root: dispatch.root,
4246        config_path: &dispatch.cli.config,
4247        output: dispatch.output,
4248        json_style: dispatch.json_style,
4249        quiet: dispatch.quiet,
4250        no_cache: dispatch.cli.no_cache,
4251        threads: Some(dispatch.threads),
4252        allow_remote_extends: dispatch.cli.allow_remote_extends,
4253    };
4254    rule_pack::run(&map_rule_pack_subcommand(subcommand), &ctx)
4255}
4256
4257fn map_rule_pack_subcommand(subcommand: RulePackCli) -> rule_pack::RulePackSubcommand {
4258    match subcommand {
4259        RulePackCli::Init {
4260            name,
4261            template,
4262            dir,
4263            no_config,
4264        } => rule_pack::RulePackSubcommand::Init(rule_pack::InitArgs {
4265            name,
4266            template,
4267            dir,
4268            no_config,
4269        }),
4270        RulePackCli::List => rule_pack::RulePackSubcommand::List,
4271        RulePackCli::Test { pack } => {
4272            rule_pack::RulePackSubcommand::Test(rule_pack::TestArgs { pack })
4273        }
4274        RulePackCli::Schema => rule_pack::RulePackSubcommand::Schema,
4275    }
4276}
4277
4278fn map_license_subcommand(sub: LicenseCli) -> license::LicenseSubcommand {
4279    match sub {
4280        LicenseCli::Activate {
4281            jwt,
4282            from_file,
4283            stdin,
4284            trial,
4285            email,
4286        } => license::LicenseSubcommand::Activate(license::ActivateArgs {
4287            raw_jwt: jwt,
4288            from_file,
4289            from_stdin: stdin,
4290            trial,
4291            email,
4292        }),
4293        LicenseCli::Status => license::LicenseSubcommand::Status,
4294        LicenseCli::Refresh => license::LicenseSubcommand::Refresh,
4295        LicenseCli::Deactivate => license::LicenseSubcommand::Deactivate,
4296    }
4297}
4298
4299fn map_telemetry_subcommand(sub: TelemetryCli) -> telemetry::TelemetryCommand {
4300    match sub {
4301        TelemetryCli::Status => telemetry::TelemetryCommand::Status,
4302        TelemetryCli::Enable => telemetry::TelemetryCommand::Enable,
4303        TelemetryCli::Disable => telemetry::TelemetryCommand::Disable,
4304        TelemetryCli::Inspect { example } => telemetry::TelemetryCommand::Inspect { example },
4305    }
4306}
4307
4308fn map_ci_subcommand(sub: CiCli) -> ci::CiCommand {
4309    match sub {
4310        command @ CiCli::PlanPrComment { .. } => map_ci_plan_pr_comment(command),
4311        command @ CiCli::PostPrComment { .. } => map_ci_post_pr_comment(command),
4312        command @ CiCli::PostReview { .. } => map_ci_post_review(command),
4313        command @ CiCli::PostCheckRun { .. } => map_ci_post_check_run(command),
4314        command @ CiCli::ReconcileReview { .. } => map_ci_reconcile_review(command),
4315    }
4316}
4317
4318fn map_ci_plan_pr_comment(command: CiCli) -> ci::CiCommand {
4319    let CiCli::PlanPrComment {
4320        body,
4321        marker_id,
4322        clean,
4323        existing_comment_id,
4324        existing_body,
4325    } = command
4326    else {
4327        unreachable!("ci plan-pr-comment mapper called with different variant");
4328    };
4329
4330    ci::CiCommand::PlanPrComment {
4331        body,
4332        marker_id,
4333        clean,
4334        existing_comment_id,
4335        existing_body,
4336    }
4337}
4338
4339fn map_ci_post_pr_comment(command: CiCli) -> ci::CiCommand {
4340    let CiCli::PostPrComment {
4341        provider,
4342        pr,
4343        mr,
4344        body,
4345        envelope,
4346        marker_id,
4347        clean,
4348        repo,
4349        project_id,
4350        api_url,
4351        dry_run,
4352    } = command
4353    else {
4354        unreachable!("ci post-pr-comment mapper called with different variant");
4355    };
4356
4357    ci::CiCommand::PostPrComment {
4358        provider: map_ci_provider(provider),
4359        target: pr.or(mr),
4360        body,
4361        envelope,
4362        marker_id,
4363        clean,
4364        repo,
4365        project_id,
4366        api_url,
4367        dry_run,
4368    }
4369}
4370
4371fn map_ci_post_review(command: CiCli) -> ci::CiCommand {
4372    let CiCli::PostReview {
4373        provider,
4374        pr,
4375        mr,
4376        envelope,
4377        repo,
4378        project_id,
4379        api_url,
4380        dry_run,
4381    } = command
4382    else {
4383        unreachable!("ci post-review mapper called with different variant");
4384    };
4385
4386    ci::CiCommand::PostReview {
4387        provider: map_ci_provider(provider),
4388        target: pr.or(mr),
4389        envelope,
4390        repo,
4391        project_id,
4392        api_url,
4393        dry_run,
4394    }
4395}
4396
4397fn map_ci_post_check_run(command: CiCli) -> ci::CiCommand {
4398    let CiCli::PostCheckRun {
4399        provider,
4400        decision,
4401        repo,
4402        head_sha,
4403        api_url,
4404        split_gates,
4405        dry_run,
4406    } = command
4407    else {
4408        unreachable!("ci post-check-run mapper called with different variant");
4409    };
4410
4411    ci::CiCommand::PostCheckRun {
4412        provider: map_ci_provider(provider),
4413        decision,
4414        repo,
4415        head_sha,
4416        api_url,
4417        split_gates,
4418        dry_run,
4419    }
4420}
4421
4422fn map_ci_reconcile_review(command: CiCli) -> ci::CiCommand {
4423    let CiCli::ReconcileReview {
4424        provider,
4425        pr,
4426        mr,
4427        envelope,
4428        repo,
4429        project_id,
4430        api_url,
4431        dry_run,
4432    } = command
4433    else {
4434        unreachable!("ci reconcile-review mapper called with different variant");
4435    };
4436
4437    ci::CiCommand::ReconcileReview {
4438        provider: map_ci_provider(provider),
4439        target: pr.or(mr),
4440        envelope,
4441        repo,
4442        project_id,
4443        api_url,
4444        dry_run,
4445    }
4446}
4447
4448fn map_ci_provider(provider: CiProviderArg) -> ci::CiProvider {
4449    match provider {
4450        CiProviderArg::Github => ci::CiProvider::Github,
4451        CiProviderArg::Gitlab => ci::CiProvider::Gitlab,
4452    }
4453}
4454
4455fn map_coverage_subcommand(sub: &CoverageCli, explain: bool) -> coverage::CoverageSubcommand {
4456    match sub {
4457        CoverageCli::Setup {
4458            yes,
4459            non_interactive,
4460            json,
4461        } => map_coverage_setup(*yes, *non_interactive, *json, explain),
4462        CoverageCli::Analyze { .. } => map_coverage_analyze(sub),
4463        CoverageCli::UploadInventory { .. } => map_coverage_upload_inventory(sub),
4464        CoverageCli::UploadSourceMaps { .. } => map_coverage_upload_source_maps(sub),
4465        CoverageCli::UploadStaticFindings { .. } => map_coverage_upload_static_findings(sub),
4466    }
4467}
4468
4469fn map_coverage_setup(
4470    yes: bool,
4471    non_interactive: bool,
4472    json: bool,
4473    explain: bool,
4474) -> coverage::CoverageSubcommand {
4475    coverage::CoverageSubcommand::Setup(coverage::SetupArgs {
4476        yes,
4477        non_interactive: non_interactive || json,
4478        json,
4479        explain,
4480    })
4481}
4482
4483fn map_coverage_analyze(sub: &CoverageCli) -> coverage::CoverageSubcommand {
4484    let CoverageCli::Analyze {
4485        runtime_coverage,
4486        cloud,
4487        api_key,
4488        api_endpoint,
4489        repo,
4490        project_id,
4491        coverage_period,
4492        environment,
4493        commit_sha,
4494        production,
4495        min_invocations_hot,
4496        min_observation_volume,
4497        low_traffic_threshold,
4498        top,
4499        blast_radius,
4500        importance,
4501    } = sub
4502    else {
4503        unreachable!("coverage analyze mapper called with non-analyze variant");
4504    };
4505    coverage::CoverageSubcommand::Analyze(coverage::AnalyzeArgs {
4506        runtime_coverage: runtime_coverage.clone(),
4507        cloud: *cloud,
4508        api_key: api_key.clone(),
4509        api_endpoint: api_endpoint.clone(),
4510        repo: repo.clone(),
4511        project_id: project_id.clone(),
4512        coverage_period: *coverage_period,
4513        environment: environment.clone(),
4514        commit_sha: commit_sha.clone(),
4515        production: *production,
4516        min_invocations_hot: *min_invocations_hot,
4517        min_observation_volume: *min_observation_volume,
4518        low_traffic_threshold: *low_traffic_threshold,
4519        top: *top,
4520        blast_radius: *blast_radius,
4521        importance: *importance,
4522    })
4523}
4524
4525fn map_coverage_upload_inventory(sub: &CoverageCli) -> coverage::CoverageSubcommand {
4526    let CoverageCli::UploadInventory {
4527        api_key,
4528        api_endpoint,
4529        project_id,
4530        git_sha,
4531        allow_dirty,
4532        exclude_paths,
4533        path_prefix,
4534        dry_run,
4535        with_callers,
4536        ignore_upload_errors,
4537    } = sub
4538    else {
4539        unreachable!("coverage inventory mapper called with non-inventory variant");
4540    };
4541    coverage::CoverageSubcommand::UploadInventory(coverage::UploadInventoryArgs {
4542        api_key: api_key.clone(),
4543        api_endpoint: api_endpoint.clone(),
4544        project_id: project_id.clone(),
4545        git_sha: git_sha.clone(),
4546        allow_dirty: *allow_dirty,
4547        exclude_paths: exclude_paths.clone(),
4548        path_prefix: path_prefix.clone(),
4549        dry_run: *dry_run,
4550        with_callers: *with_callers,
4551        ignore_upload_errors: *ignore_upload_errors,
4552    })
4553}
4554
4555fn map_coverage_upload_source_maps(sub: &CoverageCli) -> coverage::CoverageSubcommand {
4556    let CoverageCli::UploadSourceMaps {
4557        dir,
4558        include,
4559        exclude,
4560        repo,
4561        git_sha,
4562        endpoint,
4563        strip_path,
4564        dry_run,
4565        concurrency,
4566        fail_fast,
4567    } = sub
4568    else {
4569        unreachable!("coverage source-map mapper called with non-source-map variant");
4570    };
4571    coverage::CoverageSubcommand::UploadSourceMaps(coverage::UploadSourceMapsArgs {
4572        dir: dir.clone(),
4573        include: include.clone(),
4574        exclude: exclude.clone(),
4575        repo: repo.clone(),
4576        git_sha: git_sha.clone(),
4577        endpoint: endpoint.clone(),
4578        strip_path: *strip_path,
4579        dry_run: *dry_run,
4580        concurrency: *concurrency,
4581        fail_fast: *fail_fast,
4582    })
4583}
4584
4585fn map_coverage_upload_static_findings(sub: &CoverageCli) -> coverage::CoverageSubcommand {
4586    let CoverageCli::UploadStaticFindings {
4587        api_key,
4588        api_endpoint,
4589        project_id,
4590        git_sha,
4591        allow_dirty,
4592        dry_run,
4593        ignore_upload_errors,
4594    } = sub
4595    else {
4596        unreachable!("coverage static-findings mapper called with non-static variant");
4597    };
4598    coverage::CoverageSubcommand::UploadStaticFindings(coverage::UploadStaticFindingsArgs {
4599        api_key: api_key.clone(),
4600        api_endpoint: api_endpoint.clone(),
4601        project_id: project_id.clone(),
4602        git_sha: git_sha.clone(),
4603        allow_dirty: *allow_dirty,
4604        dry_run: *dry_run,
4605        ignore_upload_errors: *ignore_upload_errors,
4606    })
4607}
4608
4609struct CheckDispatchArgs {
4610    filters: IssueFilters,
4611    trace_opts: TraceOptions,
4612    include_dupes: bool,
4613    type_aware: Option<bool>,
4614    type_aware_project: Vec<std::path::PathBuf>,
4615    type_aware_require: Option<TypeAwareRequireArg>,
4616    top: Option<usize>,
4617    file: Vec<std::path::PathBuf>,
4618}
4619
4620#[derive(Clone, Copy)]
4621struct ListDispatchArgs {
4622    entry_points: bool,
4623    files: bool,
4624    plugins: bool,
4625    boundaries: bool,
4626    workspaces: bool,
4627}
4628
4629impl ListDispatchArgs {
4630    fn workspaces() -> Self {
4631        Self {
4632            entry_points: false,
4633            files: false,
4634            plugins: false,
4635            boundaries: false,
4636            workspaces: true,
4637        }
4638    }
4639}
4640
4641fn dispatch_viz(
4642    dispatch: &DispatchContext<'_>,
4643    output_path: Option<&std::path::Path>,
4644    no_open: bool,
4645    format: viz::VizFormat,
4646) -> ExitCode {
4647    let cli = dispatch.cli;
4648    let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
4649        Ok(production) => production,
4650        Err(code) => return code,
4651    };
4652    viz::run_viz(&viz::VizOptions {
4653        root: dispatch.root,
4654        config_path: &cli.config,
4655        no_cache: cli.no_cache,
4656        threads: dispatch.threads,
4657        quiet: dispatch.quiet,
4658        production,
4659        allow_remote_extends: cli.allow_remote_extends,
4660        output_path,
4661        no_open,
4662        format,
4663    })
4664}
4665
4666fn dispatch_watch(dispatch: &DispatchContext<'_>, no_clear: bool) -> ExitCode {
4667    let cli = dispatch.cli;
4668    let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
4669        Ok(production) => production,
4670        Err(code) => return code,
4671    };
4672    watch::run_watch(&watch::WatchOptions {
4673        root: dispatch.root,
4674        config_path: &cli.config,
4675        output: dispatch.output,
4676        json_style: dispatch.json_style,
4677        no_cache: cli.no_cache,
4678        threads: dispatch.threads,
4679        quiet: dispatch.quiet,
4680        allow_remote_extends: cli.allow_remote_extends,
4681        production,
4682        clear_screen: !no_clear,
4683        explain: cli.explain,
4684        include_entry_exports: cli.include_entry_exports,
4685        type_aware: cli.type_aware_override(),
4686        type_aware_projects: &cli.type_aware_project,
4687        type_aware_require: cli.type_aware_require.map(Into::into),
4688    })
4689}
4690
4691#[derive(Clone, Copy)]
4692struct FixDispatchArgs {
4693    dry_run: bool,
4694    yes: bool,
4695    no_create_config: bool,
4696}
4697
4698fn dispatch_fix(dispatch: &DispatchContext<'_>, args: FixDispatchArgs) -> ExitCode {
4699    let cli = dispatch.cli;
4700    let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
4701        Ok(production) => production,
4702        Err(code) => return code,
4703    };
4704    fix::run_fix(&fix::FixOptions {
4705        root: dispatch.root,
4706        config_path: &cli.config,
4707        output: dispatch.output,
4708        json_style: dispatch.json_style,
4709        no_cache: cli.no_cache,
4710        threads: dispatch.threads,
4711        quiet: dispatch.quiet,
4712        allow_remote_extends: cli.allow_remote_extends,
4713        dry_run: args.dry_run,
4714        yes: args.yes,
4715        production,
4716        no_create_config: args.no_create_config,
4717        type_aware: cli.type_aware_override(),
4718        type_aware_projects: &cli.type_aware_project,
4719        type_aware_require: cli.type_aware_require.map(Into::into),
4720    })
4721}
4722
4723fn dispatch_list(dispatch: &DispatchContext<'_>, args: ListDispatchArgs) -> ExitCode {
4724    let cli = dispatch.cli;
4725    let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
4726        Ok(production) => production,
4727        Err(code) => return code,
4728    };
4729    list::run_list(&ListOptions {
4730        root: dispatch.root,
4731        config_path: &cli.config,
4732        output: dispatch.output,
4733        json_style: dispatch.json_style,
4734        threads: dispatch.threads,
4735        no_cache: cli.no_cache,
4736        entry_points: args.entry_points,
4737        files: args.files,
4738        plugins: args.plugins,
4739        boundaries: args.boundaries,
4740        workspaces: args.workspaces,
4741        production,
4742        allow_remote_extends: cli.allow_remote_extends,
4743    })
4744}
4745
4746fn dispatch_check(dispatch: &DispatchContext<'_>, args: &CheckDispatchArgs) -> ExitCode {
4747    let cli = dispatch.cli;
4748    let (output, quiet, fail_on_issues) =
4749        (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
4750    let production = match dispatch.production_for(fallow_config::ProductionAnalysis::DeadCode) {
4751        Ok(production) => production,
4752        Err(code) => return code,
4753    };
4754    if let Some(code) = validate_type_aware_check_options(dispatch, args) {
4755        return code;
4756    }
4757    check::run_check(&CheckOptions {
4758        root: dispatch.root,
4759        config_path: &cli.config,
4760        output,
4761        json_style: dispatch.json_style,
4762        no_cache: cli.no_cache,
4763        threads: dispatch.threads,
4764        quiet,
4765        allow_remote_extends: cli.allow_remote_extends,
4766        fail_on_issues,
4767        filters: &args.filters,
4768        changed_since: cli.changed_since.as_deref(),
4769        diff_index: None,
4770        use_shared_diff_index: true,
4771        baseline: cli.baseline.as_deref(),
4772        save_baseline: cli.save_baseline.as_deref(),
4773        sarif_file: cli.sarif_file.as_deref(),
4774        production,
4775        production_override: Some(production),
4776        workspace: cli.workspace.as_deref(),
4777        changed_workspaces: cli.changed_workspaces.as_deref(),
4778        group_by: cli.group_by,
4779        include_dupes: args.include_dupes,
4780        type_aware: args.type_aware,
4781        type_aware_config_override: None,
4782        type_aware_projects: &args.type_aware_project,
4783        type_aware_require: args.type_aware_require.map(Into::into),
4784        trace_opts: &args.trace_opts,
4785        explain: cli.explain,
4786        top: args.top,
4787        file: &args.file,
4788        include_entry_exports: cli.include_entry_exports,
4789        summary: cli.summary,
4790        regression_opts: dispatch.regression_opts(
4791            cli.changed_since.is_some()
4792                || cli.workspace.is_some()
4793                || cli.changed_workspaces.is_some()
4794                || !args.file.is_empty(),
4795        ),
4796        retain_modules_for_health: false,
4797        defer_performance: false,
4798        analysis_snapshot: fallow_config::AnalysisSnapshot::Current,
4799    })
4800}
4801
4802fn validate_type_aware_check_options(
4803    dispatch: &DispatchContext<'_>,
4804    args: &CheckDispatchArgs,
4805) -> Option<ExitCode> {
4806    let output = dispatch.output;
4807    if !args.type_aware_project.is_empty() && args.type_aware != Some(true) {
4808        return Some(emit_error(
4809            "--type-aware-project requires --type-aware",
4810            2,
4811            output,
4812        ));
4813    }
4814    if args.type_aware_require.is_some() && args.type_aware != Some(true) {
4815        return Some(emit_error(
4816            "--type-aware-require requires --type-aware",
4817            2,
4818            output,
4819        ));
4820    }
4821    if args.trace_opts.symbol_impact.is_some() && args.type_aware != Some(true) {
4822        return Some(emit_error(
4823            "--symbol-impact requires --type-aware",
4824            2,
4825            output,
4826        ));
4827    }
4828    let focused_output = args.trace_opts.trace_export.is_some()
4829        || args.trace_opts.trace_file.is_some()
4830        || args.trace_opts.trace_dependency.is_some()
4831        || args.trace_opts.impact_closure.is_some()
4832        || args.trace_opts.symbol_impact.is_some();
4833    if focused_output
4834        && !matches!(
4835            output,
4836            fallow_config::OutputFormat::Human | fallow_config::OutputFormat::Json
4837        )
4838    {
4839        return Some(emit_error(
4840            "focused trace and impact queries support human and JSON output",
4841            2,
4842            output,
4843        ));
4844    }
4845    if args.type_aware == Some(true)
4846        && !matches!(
4847            output,
4848            fallow_config::OutputFormat::Human
4849                | fallow_config::OutputFormat::Json
4850                | fallow_config::OutputFormat::Sarif
4851                | fallow_config::OutputFormat::Compact
4852                | fallow_config::OutputFormat::Markdown
4853                | fallow_config::OutputFormat::CodeClimate
4854        )
4855    {
4856        return Some(emit_error(
4857            "--type-aware supports human, JSON, SARIF, compact, markdown, and CodeClimate output; pair CodeClimate with the JSON artifact to preserve semantic provenance",
4858            2,
4859            output,
4860        ));
4861    }
4862    None
4863}
4864
4865/// Resolve the three-state `ignoreImports` CLI override from the opt-in /
4866/// opt-out flag pair. clap's `conflicts_with` guarantees the two are never both
4867/// set, so this maps `--no-ignore-imports` -> `Some(false)`, `--ignore-imports`
4868/// -> `Some(true)`, and neither -> `None` (defer to config, which defaults to
4869/// `true`).
4870fn resolve_ignore_imports(ignore_imports: bool, no_ignore_imports: bool) -> Option<bool> {
4871    if no_ignore_imports {
4872        Some(false)
4873    } else if ignore_imports {
4874        Some(true)
4875    } else {
4876        None
4877    }
4878}
4879
4880struct DupesDispatchArgs {
4881    mode: Option<DupesMode>,
4882    near: bool,
4883    min_tokens: Option<usize>,
4884    min_lines: Option<usize>,
4885    min_occurrences: Option<usize>,
4886    threshold: Option<f64>,
4887    skip_local: bool,
4888    cross_language: bool,
4889    ignore_imports: bool,
4890    no_ignore_imports: bool,
4891    top: Option<usize>,
4892    trace: Option<String>,
4893}
4894
4895fn dispatch_dupes(dispatch: &DispatchContext<'_>, args: &DupesDispatchArgs) -> ExitCode {
4896    let cli = dispatch.cli;
4897    let (output, quiet, _fail_on_issues) =
4898        (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
4899    let production = match dispatch.production_for(fallow_config::ProductionAnalysis::Dupes) {
4900        Ok(production) => production,
4901        Err(code) => return code,
4902    };
4903    dupes::run_dupes(&DupesOptions {
4904        root: dispatch.root,
4905        config_path: &cli.config,
4906        output,
4907        json_style: dispatch.json_style,
4908        no_cache: cli.no_cache,
4909        threads: dispatch.threads,
4910        quiet,
4911        allow_remote_extends: cli.allow_remote_extends,
4912        mode: args.mode,
4913        near: args.near,
4914        min_tokens: args.min_tokens,
4915        min_lines: args.min_lines,
4916        min_occurrences: args.min_occurrences,
4917        threshold: args.threshold,
4918        skip_local: args.skip_local,
4919        cross_language: args.cross_language,
4920        ignore_imports: resolve_ignore_imports(args.ignore_imports, args.no_ignore_imports),
4921        top: args.top,
4922        baseline_path: cli.baseline.as_deref(),
4923        save_baseline_path: cli.save_baseline.as_deref(),
4924        production,
4925        production_override: Some(production),
4926        trace: args.trace.as_deref(),
4927        changed_since: cli.changed_since.as_deref(),
4928        diff_index: None,
4929        use_shared_diff_index: true,
4930        changed_files: None,
4931        workspace: cli.workspace.as_deref(),
4932        changed_workspaces: cli.changed_workspaces.as_deref(),
4933        explain: cli.explain,
4934        explain_skipped: cli.explain_skipped,
4935        summary: cli.summary,
4936        group_by: cli.group_by,
4937        performance: cli.performance,
4938    })
4939}
4940
4941struct AuditDispatchArgs {
4942    production_dead_code: bool,
4943    production_health: bool,
4944    production_dupes: bool,
4945    dead_code_baseline: Option<PathBuf>,
4946    health_baseline: Option<PathBuf>,
4947    dupes_baseline: Option<PathBuf>,
4948    max_crap: Option<f64>,
4949    coverage: Option<PathBuf>,
4950    coverage_root: Option<PathBuf>,
4951    no_css: bool,
4952    css_deep: bool,
4953    no_css_deep: bool,
4954    gate: Option<AuditGateArg>,
4955    runtime_coverage: Option<PathBuf>,
4956    min_invocations_hot: u64,
4957    gate_marker: Option<String>,
4958    brief: bool,
4959    max_decisions: usize,
4960    /// Emit the agent-contract walkthrough guide instead of the brief body.
4961    walkthrough_guide: bool,
4962    /// Post-validate an agent's judgment JSON from this path against the
4963    /// live graph.
4964    walkthrough_file: Option<PathBuf>,
4965    /// Render the existing walkthrough guide as a staged human/markdown tour.
4966    walkthrough: bool,
4967    /// Changed files to record as VIEWED before rendering the tour.
4968    mark_viewed: Vec<PathBuf>,
4969    /// Expand the Cleared panel (de-prioritized + viewed) in the tour.
4970    show_cleared: bool,
4971    /// Expand the de-prioritized units in the human focus map.
4972    show_deprioritized: bool,
4973}
4974
4975struct ResolvedAuditInputs {
4976    audit_cfg: fallow_config::AuditConfig,
4977    cache_dir: PathBuf,
4978    production: ProductionModes,
4979    dead_code_baseline: Option<PathBuf>,
4980    health_baseline: Option<PathBuf>,
4981    dupes_baseline: Option<PathBuf>,
4982    coverage: Option<PathBuf>,
4983}
4984
4985fn dispatch_audit(dispatch: &DispatchContext<'_>, args: &AuditDispatchArgs) -> ExitCode {
4986    let cli = dispatch.cli;
4987    let output = dispatch.output;
4988
4989    if cli.baseline.is_some() || cli.save_baseline.is_some() {
4990        return emit_error(
4991            "audit uses per-analysis baselines. Use --dead-code-baseline, --health-baseline, or --dupes-baseline (or save them with `fallow dead-code|health|dupes --save-baseline <file>`)",
4992            2,
4993            output,
4994        );
4995    }
4996
4997    let inputs = match resolve_audit_inputs(dispatch, args) {
4998        Ok(inputs) => inputs,
4999        Err(code) => return code,
5000    };
5001
5002    run_resolved_audit(dispatch, args, &inputs)
5003}
5004
5005fn resolve_audit_inputs(
5006    dispatch: &DispatchContext<'_>,
5007    args: &AuditDispatchArgs,
5008) -> Result<ResolvedAuditInputs, ExitCode> {
5009    let cli = dispatch.cli;
5010    let root = dispatch.root;
5011    let output = dispatch.output;
5012    let config = load_config(
5013        root,
5014        &cli.config,
5015        LoadConfigArgs {
5016            output,
5017            no_cache: cli.no_cache,
5018            threads: dispatch.threads,
5019            production: cli.production,
5020            quiet: dispatch.quiet,
5021            allow_remote_extends: cli.allow_remote_extends,
5022        },
5023    )?;
5024    let cache_dir = config.cache_dir.clone();
5025    let audit_cfg = config.audit;
5026    let production = resolve_production_modes(
5027        cli,
5028        root,
5029        output,
5030        args.production_dead_code,
5031        args.production_health,
5032        args.production_dupes,
5033    )?;
5034    let resolved_dead_code_baseline = resolve_audit_baseline_path(
5035        root,
5036        args.dead_code_baseline.as_deref(),
5037        audit_cfg.dead_code_baseline.as_deref(),
5038    );
5039    let resolved_health_baseline = resolve_audit_baseline_path(
5040        root,
5041        args.health_baseline.as_deref(),
5042        audit_cfg.health_baseline.as_deref(),
5043    );
5044    let resolved_dupes_baseline = resolve_audit_baseline_path(
5045        root,
5046        args.dupes_baseline.as_deref(),
5047        audit_cfg.dupes_baseline.as_deref(),
5048    );
5049    let coverage = args
5050        .coverage
5051        .clone()
5052        .or_else(|| std::env::var("FALLOW_COVERAGE").ok().map(PathBuf::from));
5053
5054    Ok(ResolvedAuditInputs {
5055        audit_cfg,
5056        cache_dir,
5057        production,
5058        dead_code_baseline: resolved_dead_code_baseline,
5059        health_baseline: resolved_health_baseline,
5060        dupes_baseline: resolved_dupes_baseline,
5061        coverage,
5062    })
5063}
5064
5065fn audit_css_enabled(config: &fallow_config::AuditConfig, args: &AuditDispatchArgs) -> bool {
5066    !args.no_css && config.css.unwrap_or(true)
5067}
5068
5069fn audit_css_deep_enabled(config: &fallow_config::AuditConfig, args: &AuditDispatchArgs) -> bool {
5070    audit_css_enabled(config, args)
5071        && !args.no_css_deep
5072        && (args.css_deep || config.css_deep.unwrap_or(true))
5073}
5074
5075fn run_resolved_audit(
5076    dispatch: &DispatchContext<'_>,
5077    args: &AuditDispatchArgs,
5078    inputs: &ResolvedAuditInputs,
5079) -> ExitCode {
5080    let cli = dispatch.cli;
5081    audit::run_audit_with_type_aware(
5082        &audit::AuditOptions {
5083            root: dispatch.root,
5084            config_path: &cli.config,
5085            cache_dir: &inputs.cache_dir,
5086            output: dispatch.output,
5087            json_style: dispatch.json_style,
5088            no_cache: cli.no_cache,
5089            threads: dispatch.threads,
5090            quiet: dispatch.quiet,
5091            allow_remote_extends: cli.allow_remote_extends,
5092            changed_since: cli.changed_since.as_deref(),
5093            production: cli.production,
5094            production_dead_code: Some(inputs.production.dead_code),
5095            production_health: Some(inputs.production.health),
5096            production_dupes: Some(inputs.production.dupes),
5097            workspace: cli.workspace.as_deref(),
5098            changed_workspaces: cli.changed_workspaces.as_deref(),
5099            explain: cli.explain,
5100            explain_skipped: cli.explain_skipped,
5101            performance: cli.performance,
5102            group_by: cli.group_by,
5103            dead_code_baseline: inputs.dead_code_baseline.as_deref(),
5104            health_baseline: inputs.health_baseline.as_deref(),
5105            dupes_baseline: inputs.dupes_baseline.as_deref(),
5106            health_baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
5107            max_crap: args.max_crap,
5108            coverage: inputs.coverage.as_deref(),
5109            coverage_root: args.coverage_root.as_deref(),
5110            gate: args.gate.map_or(inputs.audit_cfg.gate, Into::into),
5111            include_entry_exports: cli.include_entry_exports,
5112            // Styling analytics, including deep cross-file reachability, is on
5113            // by default in `fallow audit`; both layers remain verdict-neutral
5114            // unless a user escalates a styling rule to error.
5115            css: audit_css_enabled(&inputs.audit_cfg, args),
5116            css_deep: audit_css_deep_enabled(&inputs.audit_cfg, args),
5117            runtime_coverage: args.runtime_coverage.as_deref(),
5118            min_invocations_hot: args.min_invocations_hot,
5119            brief: args.brief,
5120            max_decisions: args.max_decisions,
5121            walkthrough_guide: args.walkthrough_guide,
5122            walkthrough: args.walkthrough,
5123            mark_viewed: &args.mark_viewed,
5124            show_cleared: args.show_cleared,
5125            walkthrough_file: args.walkthrough_file.as_deref(),
5126            show_deprioritized: args.show_deprioritized,
5127        },
5128        args.gate_marker.as_deref(),
5129        audit::AuditTypeAwareOptions {
5130            enabled: cli.type_aware_override(),
5131            config_default: inputs.audit_cfg.type_aware,
5132            projects: &cli.type_aware_project,
5133            require: cli.type_aware_require.map(Into::into),
5134        },
5135    )
5136}
5137
5138/// Dispatch `fallow decision-surface`: the separable apex. Reuses the audit
5139/// input resolution in brief mode (changed-code scope) with all gating /
5140/// coverage / baseline knobs defaulted, then renders ONLY the decision surface.
5141fn dispatch_decision_surface(dispatch: &DispatchContext<'_>, max_decisions: usize) -> ExitCode {
5142    let args = decision_surface_audit_args(max_decisions);
5143    let inputs = match resolve_audit_inputs(dispatch, &args) {
5144        Ok(inputs) => inputs,
5145        Err(code) => return code,
5146    };
5147    audit::run_decision_surface(&decision_surface_audit_options(
5148        dispatch,
5149        &inputs,
5150        max_decisions,
5151    ))
5152}
5153
5154fn decision_surface_audit_args(max_decisions: usize) -> AuditDispatchArgs {
5155    AuditDispatchArgs {
5156        production_dead_code: false,
5157        production_health: false,
5158        production_dupes: false,
5159        dead_code_baseline: None,
5160        health_baseline: None,
5161        dupes_baseline: None,
5162        max_crap: None,
5163        coverage: None,
5164        coverage_root: None,
5165        no_css: true,
5166        css_deep: false,
5167        no_css_deep: false,
5168        gate: None,
5169        runtime_coverage: None,
5170        min_invocations_hot: 0,
5171        gate_marker: None,
5172        brief: true,
5173        max_decisions,
5174        walkthrough_guide: false,
5175        walkthrough_file: None,
5176        walkthrough: false,
5177        mark_viewed: Vec::new(),
5178        show_cleared: false,
5179        show_deprioritized: false,
5180    }
5181}
5182
5183fn decision_surface_audit_options<'a>(
5184    dispatch: &'a DispatchContext<'a>,
5185    inputs: &'a ResolvedAuditInputs,
5186    max_decisions: usize,
5187) -> audit::AuditOptions<'a> {
5188    let cli = dispatch.cli;
5189    audit::AuditOptions {
5190        root: dispatch.root,
5191        config_path: &cli.config,
5192        cache_dir: &inputs.cache_dir,
5193        output: dispatch.output,
5194        json_style: dispatch.json_style,
5195        no_cache: cli.no_cache,
5196        threads: dispatch.threads,
5197        quiet: dispatch.quiet,
5198        allow_remote_extends: cli.allow_remote_extends,
5199        changed_since: cli.changed_since.as_deref(),
5200        production: cli.production,
5201        production_dead_code: Some(inputs.production.dead_code),
5202        production_health: Some(inputs.production.health),
5203        production_dupes: Some(inputs.production.dupes),
5204        workspace: cli.workspace.as_deref(),
5205        changed_workspaces: cli.changed_workspaces.as_deref(),
5206        explain: cli.explain,
5207        explain_skipped: cli.explain_skipped,
5208        performance: cli.performance,
5209        group_by: cli.group_by,
5210        dead_code_baseline: inputs.dead_code_baseline.as_deref(),
5211        health_baseline: inputs.health_baseline.as_deref(),
5212        dupes_baseline: inputs.dupes_baseline.as_deref(),
5213        health_baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
5214        max_crap: None,
5215        coverage: None,
5216        coverage_root: None,
5217        gate: inputs.audit_cfg.gate,
5218        include_entry_exports: cli.include_entry_exports,
5219        // Decision-surface (brief apex) does not render styling; keep it lean.
5220        css: false,
5221        css_deep: false,
5222        runtime_coverage: None,
5223        min_invocations_hot: 0,
5224        brief: true,
5225        max_decisions,
5226        walkthrough_guide: false,
5227        walkthrough: false,
5228        mark_viewed: &[],
5229        show_cleared: false,
5230        walkthrough_file: None,
5231        show_deprioritized: false,
5232    }
5233}
5234
5235struct HealthDispatchArgs<'a> {
5236    max_cyclomatic: Option<u16>,
5237    max_cognitive: Option<u16>,
5238    max_crap: Option<f64>,
5239    top: Option<usize>,
5240    sort: health::SortBy,
5241    complexity: bool,
5242    complexity_breakdown: bool,
5243    file_scores: bool,
5244    coverage_gaps: bool,
5245    hotspots: bool,
5246    ownership: bool,
5247    ownership_emails: Option<fallow_config::EmailMode>,
5248    targets: bool,
5249    type_coupling: bool,
5250    css: bool,
5251    effort: Option<EffortFilter>,
5252    score: bool,
5253    min_score: Option<f64>,
5254    min_severity: Option<fallow_output::FindingSeverity>,
5255    report_only: bool,
5256    since: Option<&'a str>,
5257    min_commits: Option<u32>,
5258    save_snapshot: Option<&'a Option<String>>,
5259    trend: bool,
5260    coverage: Option<&'a std::path::Path>,
5261    coverage_root: Option<&'a std::path::Path>,
5262    runtime_coverage: Option<&'a std::path::Path>,
5263    min_invocations_hot: u64,
5264    min_observation_volume: Option<u32>,
5265    low_traffic_threshold: Option<f64>,
5266}
5267
5268struct ResolvedHealthCoverageInputs {
5269    coverage: Option<PathBuf>,
5270    coverage_root: Option<PathBuf>,
5271}
5272
5273fn resolve_health_coverage_inputs(
5274    dispatch: &DispatchContext<'_>,
5275    cli_coverage: Option<&std::path::Path>,
5276    cli_coverage_root: Option<&std::path::Path>,
5277) -> Result<ResolvedHealthCoverageInputs, ExitCode> {
5278    let env_coverage = path_from_env("FALLOW_COVERAGE");
5279    let env_coverage_root = path_from_env("FALLOW_COVERAGE_ROOT");
5280    let needs_config_coverage = cli_coverage.is_none() && env_coverage.is_none();
5281    let needs_config_coverage_root = cli_coverage_root.is_none() && env_coverage_root.is_none();
5282    let config_health = if needs_config_coverage || needs_config_coverage_root {
5283        Some(
5284            load_config(
5285                dispatch.root,
5286                &dispatch.cli.config,
5287                LoadConfigArgs {
5288                    output: dispatch.output,
5289                    no_cache: dispatch.cli.no_cache,
5290                    threads: dispatch.threads,
5291                    production: dispatch.cli.production,
5292                    quiet: dispatch.quiet,
5293                    allow_remote_extends: dispatch.cli.allow_remote_extends,
5294                },
5295            )?
5296            .health,
5297        )
5298    } else {
5299        None
5300    };
5301
5302    Ok(ResolvedHealthCoverageInputs {
5303        coverage: cli_coverage
5304            .map(std::path::Path::to_path_buf)
5305            .or(env_coverage)
5306            .or_else(|| {
5307                config_health
5308                    .as_ref()
5309                    .and_then(|health| health.coverage.clone())
5310            }),
5311        coverage_root: cli_coverage_root
5312            .map(std::path::Path::to_path_buf)
5313            .or(env_coverage_root)
5314            .or_else(|| {
5315                config_health
5316                    .as_ref()
5317                    .and_then(|health| health.coverage_root.clone())
5318            }),
5319    })
5320}
5321
5322fn path_from_env(name: &str) -> Option<PathBuf> {
5323    std::env::var_os(name)
5324        .filter(|value| !value.is_empty())
5325        .map(PathBuf::from)
5326}
5327
5328fn validate_health_report_only_gate(
5329    report_only: bool,
5330    min_score: Option<f64>,
5331    min_severity: Option<fallow_output::FindingSeverity>,
5332    output: fallow_config::OutputFormat,
5333) -> Result<(), ExitCode> {
5334    if report_only && (min_score.is_some() || min_severity.is_some()) {
5335        return Err(emit_error(
5336            "--report-only cannot be combined with --min-score or --min-severity. \
5337             --report-only always exits 0; drop it to gate on score/severity, or \
5338             drop the gate flags to stay advisory.",
5339            2,
5340            output,
5341        ));
5342    }
5343
5344    Ok(())
5345}
5346
5347fn resolve_runtime_coverage_options(
5348    runtime_coverage: Option<&std::path::Path>,
5349    min_invocations_hot: u64,
5350    min_observation_volume: Option<u32>,
5351    low_traffic_threshold: Option<f64>,
5352    output: fallow_config::OutputFormat,
5353) -> Result<Option<fallow_engine::health::RuntimeCoverageOptions>, ExitCode> {
5354    let Some(path) = runtime_coverage else {
5355        return Ok(None);
5356    };
5357
5358    health::coverage::prepare_options(
5359        path,
5360        min_invocations_hot,
5361        min_observation_volume,
5362        low_traffic_threshold,
5363        output,
5364    )
5365    .map(Some)
5366}
5367
5368fn dispatch_health(dispatch: &DispatchContext<'_>, args: &HealthDispatchArgs<'_>) -> ExitCode {
5369    let cli = dispatch.cli;
5370    let root = dispatch.root;
5371    let (output, _quiet, _fail_on_issues) =
5372        (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
5373    if let Err(code) = validate_health_report_only_gate(
5374        args.report_only,
5375        args.min_score,
5376        args.min_severity,
5377        output,
5378    ) {
5379        return code;
5380    }
5381    let runtime_coverage = match resolve_runtime_coverage_options(
5382        args.runtime_coverage,
5383        args.min_invocations_hot,
5384        args.min_observation_volume,
5385        args.low_traffic_threshold,
5386        output,
5387    ) {
5388        Ok(options) => options,
5389        Err(code) => return code,
5390    };
5391    let production = match resolve_production_modes(cli, root, output, false, false, false) {
5392        Ok(modes) => modes.for_analysis(fallow_config::ProductionAnalysis::Health),
5393        Err(code) => return code,
5394    };
5395    let coverage_inputs =
5396        match resolve_health_coverage_inputs(dispatch, args.coverage, args.coverage_root) {
5397            Ok(inputs) => inputs,
5398            Err(code) => return code,
5399        };
5400    let run = derive_health_dispatch_run(args, output, &coverage_inputs, runtime_coverage);
5401    run_health_dispatch(dispatch, args, ResolvedHealthDispatch { run, production })
5402}
5403
5404fn derive_health_dispatch_run<'a>(
5405    args: &'a HealthDispatchArgs<'a>,
5406    output: fallow_config::OutputFormat,
5407    coverage_inputs: &'a ResolvedHealthCoverageInputs,
5408    runtime_coverage: Option<fallow_engine::health::RuntimeCoverageOptions>,
5409) -> fallow_engine::health::HealthRunOptions<'a> {
5410    let mut run = fallow_engine::health::derive_health_run_options(
5411        fallow_engine::health::HealthRunOptionsInput {
5412            output,
5413            thresholds: health_threshold_overrides(args),
5414            top: args.top,
5415            sort: args.sort.clone().into(),
5416            complexity: args.complexity,
5417            file_scores: args.file_scores,
5418            coverage_gaps: args.coverage_gaps,
5419            hotspots: args.hotspots,
5420            ownership: args.ownership,
5421            ownership_emails: args.ownership_emails,
5422            targets: args.targets,
5423            css: args.css,
5424            effort: args.effort.map(EffortFilter::to_estimate),
5425            score: args.score,
5426            gates: health_gate_options(args),
5427            snapshot_requested: args.save_snapshot.is_some(),
5428            trend: args.trend,
5429            since: args.since,
5430            min_commits: args.min_commits,
5431            coverage_inputs: health_coverage_inputs(coverage_inputs),
5432            runtime_coverage,
5433        },
5434    );
5435    if args.type_coupling && !run.sections.any_section {
5436        run.sections = fallow_engine::health::DerivedHealthSections {
5437            any_section: true,
5438            complexity: false,
5439            file_scores: false,
5440            coverage_gaps: false,
5441            hotspots: false,
5442            targets: false,
5443            css: false,
5444            score: false,
5445            force_full: false,
5446            score_only_output: false,
5447        };
5448    }
5449    run
5450}
5451
5452fn health_threshold_overrides(
5453    args: &HealthDispatchArgs<'_>,
5454) -> fallow_engine::health::HealthThresholdOverrides {
5455    fallow_engine::health::HealthThresholdOverrides {
5456        max_cyclomatic: args.max_cyclomatic,
5457        max_cognitive: args.max_cognitive,
5458        max_crap: args.max_crap,
5459    }
5460}
5461
5462fn health_gate_options(args: &HealthDispatchArgs<'_>) -> fallow_engine::health::HealthGateOptions {
5463    fallow_engine::health::HealthGateOptions {
5464        min_score: args.min_score,
5465        min_severity: args.min_severity,
5466        report_only: args.report_only,
5467    }
5468}
5469
5470fn health_coverage_inputs(
5471    coverage_inputs: &ResolvedHealthCoverageInputs,
5472) -> fallow_engine::health::HealthCoverageInputs<'_> {
5473    fallow_engine::health::HealthCoverageInputs {
5474        coverage: coverage_inputs.coverage.as_deref(),
5475        coverage_root: coverage_inputs.coverage_root.as_deref(),
5476    }
5477}
5478
5479/// Resolved inputs threaded from `dispatch_health` into the `HealthOptions`
5480/// builder. Owns the normalized engine run contract and resolved production
5481/// mode.
5482struct ResolvedHealthDispatch<'a> {
5483    run: fallow_engine::health::HealthRunOptions<'a>,
5484    production: bool,
5485}
5486
5487/// Build `HealthOptions` from the parsed args plus the resolved dispatch inputs,
5488/// then run the health analysis.
5489fn run_health_dispatch(
5490    dispatch: &DispatchContext<'_>,
5491    args: &HealthDispatchArgs<'_>,
5492    resolved: ResolvedHealthDispatch<'_>,
5493) -> ExitCode {
5494    let cli = dispatch.cli;
5495    let (output, quiet, _fail_on_issues) =
5496        (dispatch.output, dispatch.quiet, dispatch.fail_on_issues);
5497    let run = resolved.run;
5498    let sections = run.sections;
5499    let production = resolved.production;
5500    health::run_health(
5501        &HealthOptions {
5502            root: dispatch.root,
5503            config_path: &cli.config,
5504            output,
5505            no_cache: cli.no_cache,
5506            threads: dispatch.threads,
5507            quiet,
5508            thresholds: run.thresholds,
5509            top: run.top,
5510            sort: run.sort,
5511            production,
5512            production_override: Some(production),
5513            allow_remote_extends: cli.allow_remote_extends,
5514            changed_since: cli.changed_since.as_deref(),
5515            diff_index: None,
5516            use_shared_diff_index: true,
5517            workspace: cli.workspace.as_deref(),
5518            changed_workspaces: cli.changed_workspaces.as_deref(),
5519            baseline: cli.baseline.as_deref(),
5520            save_baseline: cli.save_baseline.as_deref(),
5521            baseline_mode: cli.baseline_mode.unwrap_or_default().into(),
5522            baseline_mode_explicit: cli.baseline_mode.is_some(),
5523            complexity: sections.complexity,
5524            file_scores: sections.file_scores,
5525            coverage_gaps: sections.coverage_gaps,
5526            config_activates_coverage_gaps: !sections.any_section,
5527            hotspots: sections.hotspots,
5528            ownership: run.ownership,
5529            ownership_emails: run.ownership_emails,
5530            targets: sections.targets,
5531            css: sections.css,
5532            css_deep: false,
5533            force_full: sections.force_full,
5534            score_only_output: sections.score_only_output,
5535            enforce_coverage_gap_gate: true,
5536            effort: run.effort,
5537            score: sections.score,
5538            gates: run.gates,
5539            since: run.since,
5540            min_commits: run.min_commits,
5541            explain: cli.explain,
5542            summary: cli.summary,
5543            save_snapshot: args
5544                .save_snapshot
5545                .map(|opt| PathBuf::from(opt.as_deref().unwrap_or_default())),
5546            trend: args.trend,
5547            coverage_inputs: run.coverage_inputs,
5548            performance: cli.performance,
5549            runtime_coverage: run.runtime_coverage,
5550            churn_file: cli.churn_file.as_deref(),
5551            analysis_identity: fallow_types::semantic::SemanticAnalysisIdentity::default(),
5552            complexity_breakdown: args.complexity_breakdown,
5553            group_by: cli.group_by.map(Into::into),
5554        },
5555        dispatch.json_style,
5556        &health::TypeAwareHealthOptions {
5557            enabled: cli.type_aware_override(),
5558            requested: args.type_coupling,
5559            unfiltered: health_type_coupling_is_default_section(args),
5560            projects: &cli.type_aware_project,
5561            require: cli.type_aware_require.map(Into::into),
5562        },
5563    )
5564}
5565
5566fn health_type_coupling_is_default_section(args: &HealthDispatchArgs<'_>) -> bool {
5567    !args.complexity
5568        && !args.file_scores
5569        && !args.coverage_gaps
5570        && !args.hotspots
5571        && !args.ownership
5572        && !args.targets
5573        && !args.css
5574        && !args.score
5575        && args.min_score.is_none()
5576        && args.min_severity.is_none()
5577        && args.runtime_coverage.is_none()
5578}
5579
5580#[cfg(test)]
5581mod tests {
5582    use super::*;
5583
5584    /// Validates that the CLI definition has no flag name collisions, missing
5585    /// fields, or other structural errors. Catches issues like a global alias
5586    /// `--base` colliding with a subcommand's `--base` flag.
5587    #[test]
5588    fn cli_definition_has_no_flag_collisions() {
5589        use clap::CommandFactory;
5590        Cli::command().debug_assert();
5591    }
5592
5593    #[test]
5594    fn impact_statusline_subcommand_parses() {
5595        use clap::Parser;
5596
5597        let cli = Cli::try_parse_from(["fallow", "impact", "statusline"]).expect("argv parses");
5598        assert!(matches!(
5599            cli.command,
5600            Some(Command::Impact {
5601                subcommand: Some(ImpactCli::Statusline),
5602                ..
5603            })
5604        ));
5605    }
5606
5607    #[test]
5608    fn impact_statusline_bypasses_command_epilogue() {
5609        use clap::Parser;
5610
5611        let statusline =
5612            Cli::try_parse_from(["fallow", "impact", "statusline"]).expect("argv parses");
5613        assert!(is_impact_statusline(&statusline));
5614
5615        let status = Cli::try_parse_from(["fallow", "impact", "status"]).expect("argv parses");
5616        assert!(!is_impact_statusline(&status));
5617
5618        let all_statusline =
5619            Cli::try_parse_from(["fallow", "impact", "--all", "statusline"]).expect("argv parses");
5620        assert!(!is_impact_statusline(&all_statusline));
5621    }
5622
5623    #[test]
5624    fn regression_baseline_help_explains_the_default_destination() {
5625        use clap::CommandFactory;
5626        let help = Cli::command().render_long_help().to_string();
5627
5628        assert!(help.contains("Omit PATH to update regression.baseline"));
5629        assert!(help.contains("discovered fallow config"));
5630        assert!(help.contains("create .fallowrc.json when none exists"));
5631    }
5632
5633    /// The root `--help` cheat sheet is a static const that cannot call the
5634    /// shared renderer, so this test is the only guard that it stays in sync
5635    /// with `TASK_MATRIX`. Every row's command string must appear verbatim.
5636    #[test]
5637    fn after_help_lists_every_task_matrix_command() {
5638        for row in crate::task_matrix::TASK_MATRIX {
5639            assert!(
5640                TOP_LEVEL_AFTER_LONG_HELP.contains(row.command),
5641                "root --help cheat sheet is missing task-matrix command '{}'; \
5642                 update the top_level_task_cheat_sheet! fragment to match TASK_MATRIX",
5643                row.command
5644            );
5645        }
5646    }
5647
5648    /// The curated command groups replace clap's auto-generated subcommand
5649    /// list, so a new subcommand stays invisible in `fallow --help` unless it
5650    /// is added here. Substring matching is not enough (e.g. `--trace`
5651    /// contains `trace`), so each name must lead a group line. The full
5652    /// `--help` surface is the one that must stay complete; `-h` is the
5653    /// curated progressive subset.
5654    #[test]
5655    fn after_help_lists_every_visible_subcommand() {
5656        use clap::CommandFactory;
5657
5658        for sub in Cli::command().get_subcommands() {
5659            if sub.is_hide_set() {
5660                continue;
5661            }
5662            let name = sub.get_name();
5663            let listed = TOP_LEVEL_AFTER_LONG_HELP
5664                .lines()
5665                .any(|line| line.split_whitespace().next() == Some(name));
5666            assert!(
5667                listed,
5668                "root --help command list is missing subcommand '{name}'; \
5669                 add it to a top_level_*_command_groups! section"
5670            );
5671        }
5672    }
5673
5674    /// `-h` is the progressive entry point: it must stay scannable (ecosystem
5675    /// norm is 40-80 lines) while leading with the task cheat sheet and
5676    /// closing with the pointer to the complete `--help` surface.
5677    #[test]
5678    fn short_help_stays_scannable_with_cheat_sheet_and_pointer() {
5679        use clap::CommandFactory;
5680
5681        let help = Cli::command().render_help().to_string();
5682        let lines = help.lines().count();
5683        assert!(
5684            lines < 90,
5685            "root -h grew to {lines} lines; keep the short surface under 90 \
5686             (curate hide_short_help and the short after-help instead)"
5687        );
5688        assert!(help.contains("When the agent is about to..."));
5689        assert!(help.contains("Run fallow --help for the complete command list."));
5690    }
5691
5692    /// The high-value and coarse admin commands each get a distinct telemetry
5693    /// workflow instead of the `Unknown` catch-all, so command families stay
5694    /// answerable without uploading raw command lines.
5695    #[test]
5696    fn high_value_commands_route_to_distinct_workflows() {
5697        use clap::Parser;
5698        use fallow_config::OutputFormat;
5699
5700        let distinct = [
5701            (vec!["fallow", "impact"], telemetry::Workflow::Impact),
5702            (vec!["fallow", "security"], telemetry::Workflow::Security),
5703            (vec!["fallow", "fix"], telemetry::Workflow::Fix),
5704            (
5705                vec!["fallow", "explain", "unused-exports"],
5706                telemetry::Workflow::Explain,
5707            ),
5708            (
5709                vec!["fallow", "watch"],
5710                telemetry::Workflow::CodeQualityReview,
5711            ),
5712            (
5713                vec!["fallow", "list"],
5714                telemetry::Workflow::ProjectInventory,
5715            ),
5716            (
5717                vec!["fallow", "workspaces"],
5718                telemetry::Workflow::ProjectInventory,
5719            ),
5720            (
5721                vec!["fallow", "schema"],
5722                telemetry::Workflow::ProjectInventory,
5723            ),
5724            (vec!["fallow", "init"], telemetry::Workflow::Setup),
5725            (
5726                vec!["fallow", "hooks", "install", "--target", "git"],
5727                telemetry::Workflow::Setup,
5728            ),
5729            (vec!["fallow", "config-schema"], telemetry::Workflow::Setup),
5730            (vec!["fallow", "plugin-schema"], telemetry::Workflow::Setup),
5731            (
5732                vec!["fallow", "rule-pack-schema"],
5733                telemetry::Workflow::Setup,
5734            ),
5735            (vec!["fallow", "config"], telemetry::Workflow::Setup),
5736            (
5737                vec!["fallow", "ci-template", "gitlab"],
5738                telemetry::Workflow::Setup,
5739            ),
5740            (vec!["fallow", "migrate"], telemetry::Workflow::Setup),
5741            (
5742                vec!["fallow", "telemetry", "status"],
5743                telemetry::Workflow::Setup,
5744            ),
5745            (vec!["fallow", "setup-hooks"], telemetry::Workflow::Setup),
5746            (
5747                vec!["fallow", "audit-cache", "remove", "--root", "."],
5748                telemetry::Workflow::Setup,
5749            ),
5750            (
5751                vec!["fallow", "license", "status"],
5752                telemetry::Workflow::License,
5753            ),
5754        ];
5755        for (argv, expected) in distinct {
5756            let cli = Cli::try_parse_from(&argv).expect("argv parses");
5757            assert_eq!(
5758                telemetry_workflow_for_command(cli.command.as_ref(), OutputFormat::Json),
5759                expected,
5760                "{argv:?} should map to {expected:?}"
5761            );
5762        }
5763    }
5764
5765    /// `-v`, `-V`, and `--version` must all trigger clap's Version action so
5766    /// the version prints regardless of which spelling the user reaches for
5767    /// (issue #916). clap surfaces a Version action from `try_get_matches_from`
5768    /// as the `DisplayVersion` error kind.
5769    #[test]
5770    fn version_flag_accepts_lower_v_upper_v_and_long() {
5771        use clap::CommandFactory;
5772        for argv in [["fallow", "-v"], ["fallow", "-V"], ["fallow", "--version"]] {
5773            let err = Cli::command()
5774                .try_get_matches_from(argv)
5775                .expect_err("version flag should short-circuit parsing");
5776            assert_eq!(
5777                err.kind(),
5778                clap::error::ErrorKind::DisplayVersion,
5779                "{argv:?} should trigger the Version action"
5780            );
5781        }
5782    }
5783
5784    /// Guard against deferred-work wording leaking into clap-rendered help.
5785    /// `stub`, `placeholder`, and `not yet` framings tell users the feature
5786    /// is broken or pending; they belong in tracked issues, not in `--help`.
5787    /// Walk every (sub)command and assert each rendered long-help is clean.
5788    #[test]
5789    fn cli_help_text_contains_no_implementation_status_wording() {
5790        use clap::CommandFactory;
5791        let mut root = Cli::command();
5792        let mut violations: Vec<(String, String)> = Vec::new();
5793        visit_help(&mut root, "fallow", &mut violations);
5794        assert!(
5795            violations.is_empty(),
5796            "found implementation-status wording in --help output:\n{}",
5797            violations
5798                .iter()
5799                .map(|(cmd, line)| format!("  {cmd}: {line}"))
5800                .collect::<Vec<_>>()
5801                .join("\n")
5802        );
5803    }
5804
5805    #[test]
5806    fn top_level_help_groups_commands_by_workflow() {
5807        use clap::CommandFactory;
5808        let help = Cli::command().render_long_help().to_string();
5809        let expected_order = [
5810            "Analysis:",
5811            "  dead-code",
5812            "  dupes",
5813            "  health",
5814            "  flags",
5815            "  security",
5816            "  audit",
5817            "Workflow:",
5818            "  watch",
5819            "  fix",
5820            "Project inspection:",
5821            "  list",
5822            "  workspaces",
5823            "  explain",
5824            "  impact",
5825            "  viz",
5826            "Setup and configuration:",
5827            "  init",
5828            "  recommend",
5829            "  migrate",
5830            "  config",
5831            "  config-schema",
5832            "  plugin-schema",
5833            "  plugin-check",
5834            "  rule-pack-schema",
5835            "Automation and CI:",
5836            "  ci",
5837            "  ci-template",
5838            "  hooks",
5839            "  setup-hooks",
5840            "Runtime coverage:",
5841            "  coverage",
5842            "  license",
5843            "Reference:",
5844            "  schema",
5845            "  help",
5846            "Options:",
5847        ];
5848        let mut cursor = 0;
5849        for needle in expected_order {
5850            let Some(offset) = help[cursor..].find(needle) else {
5851                panic!("top-level help missing `{needle}` after byte {cursor}:\n{help}");
5852            };
5853            cursor += offset + needle.len();
5854        }
5855    }
5856
5857    #[test]
5858    fn security_help_hides_globals_rejected_by_security_validator() {
5859        let help = render_security_help(SecurityHelpTarget::Parent);
5860
5861        for long in SECURITY_UNSUPPORTED_GLOBAL_LONGS {
5862            assert!(
5863                !help_contains_long_flag(&help, long),
5864                "security help must hide unsupported --{long}:\n{help}"
5865            );
5866        }
5867
5868        for long in [
5869            "root",
5870            "config",
5871            "format",
5872            "quiet",
5873            "no-cache",
5874            "threads",
5875            "changed-since",
5876            "diff-file",
5877            "diff-stdin",
5878            "workspace",
5879            "changed-workspaces",
5880            "ci",
5881            "fail-on-issues",
5882            "sarif-file",
5883            "summary",
5884            "output-file",
5885            "max-file-size",
5886            "explain",
5887            "surface",
5888        ] {
5889            assert!(
5890                help_contains_long_flag(&help, long),
5891                "security help must keep supported --{long}:\n{help}"
5892            );
5893        }
5894    }
5895
5896    #[test]
5897    fn security_help_detection_covers_subcommand_and_help_alias_forms() {
5898        assert_eq!(
5899            security_help_target(["security", "--help"]),
5900            Some(SecurityHelpTarget::Parent)
5901        );
5902        assert_eq!(
5903            security_help_target(["security", "-h"]),
5904            Some(SecurityHelpTarget::Parent)
5905        );
5906        assert_eq!(
5907            security_help_target(["--format", "json", "security", "--help"]),
5908            Some(SecurityHelpTarget::Parent)
5909        );
5910        assert_eq!(
5911            security_help_target(["help", "security"]),
5912            Some(SecurityHelpTarget::Parent)
5913        );
5914        assert_eq!(
5915            security_help_target(["security", "survivors", "--help"]),
5916            Some(SecurityHelpTarget::Survivors)
5917        );
5918        assert_eq!(
5919            security_help_target(["security", "survivors", "-h"]),
5920            Some(SecurityHelpTarget::Survivors)
5921        );
5922        assert_eq!(
5923            security_help_target(["help", "security", "survivors"]),
5924            Some(SecurityHelpTarget::Survivors)
5925        );
5926        assert_eq!(
5927            security_help_target(["security", "blind-spots", "--help"]),
5928            Some(SecurityHelpTarget::BlindSpots)
5929        );
5930        assert_eq!(
5931            security_help_target(["help", "security", "blind-spots"]),
5932            Some(SecurityHelpTarget::BlindSpots)
5933        );
5934        assert_eq!(security_help_target(["health", "--help"]), None);
5935        assert_eq!(security_help_target(["help", "health"]), None);
5936    }
5937
5938    #[test]
5939    fn security_unsupported_global_validator_matches_hidden_help_contract() {
5940        for (argv, expected) in [
5941            (vec!["fallow", "security", "--performance"], "--performance"),
5942            (
5943                vec!["fallow", "security", "--baseline", "base.json"],
5944                "--baseline",
5945            ),
5946            (
5947                vec!["fallow", "security", "--dupes-mode", "weak"],
5948                "--dupes-mode",
5949            ),
5950        ] {
5951            let cli = Cli::try_parse_from(argv).expect("security global parses before validation");
5952            assert_eq!(unsupported_security_global(&cli), Some(expected));
5953        }
5954
5955        let explain = Cli::try_parse_from(["fallow", "security", "--explain"])
5956            .expect("security --explain parses");
5957        assert_eq!(unsupported_security_global(&explain), None);
5958    }
5959
5960    #[test]
5961    fn programmatic_common_options_track_analysis_affecting_cli_globals() {
5962        use clap::CommandFactory;
5963
5964        let cli_flags: std::collections::BTreeSet<String> = Cli::command()
5965            .get_arguments()
5966            .filter(|arg| arg.is_global_set())
5967            .filter_map(|arg| arg.get_long().map(str::to_owned))
5968            .filter(|name| {
5969                matches!(
5970                    name.as_str(),
5971                    "root"
5972                        | "config"
5973                        | "allow-remote-extends"
5974                        | "no-cache"
5975                        | "threads"
5976                        | "changed-since"
5977                        | "diff-file"
5978                        | "production"
5979                        | "workspace"
5980                        | "changed-workspaces"
5981                        | "explain"
5982                )
5983            })
5984            .collect();
5985        let programmatic_flags: std::collections::BTreeSet<String> =
5986            fallow_api::COMMON_ANALYSIS_OPTION_FLAGS
5987                .iter()
5988                .map(|flag| (*flag).to_owned())
5989                .collect();
5990
5991        assert_eq!(programmatic_flags, cli_flags);
5992    }
5993
5994    #[test]
5995    fn dead_code_registry_filter_flags_are_exposed_by_clap() {
5996        use clap::CommandFactory;
5997
5998        let cli = Cli::command();
5999        let dead_code = cli
6000            .get_subcommands()
6001            .find(|command| command.get_name() == "dead-code")
6002            .expect("dead-code subcommand is registered");
6003        let cli_flags: std::collections::BTreeSet<String> = dead_code
6004            .get_arguments()
6005            .filter_map(|arg| arg.get_long().map(|long| format!("--{long}")))
6006            .collect();
6007
6008        for flag in fallow_types::issue_meta::DEAD_CODE_FILTER_FLAGS.iter() {
6009            assert!(
6010                cli_flags.contains(*flag),
6011                "registry filter flag {flag} is missing from dead-code clap args"
6012            );
6013        }
6014    }
6015
6016    fn help_contains_long_flag(help: &str, long: &str) -> bool {
6017        let flag = format!("--{long}");
6018        help.split(|c: char| c.is_whitespace() || c == ',' || c == '[' || c == ']')
6019            .any(|token| token == flag)
6020    }
6021
6022    fn visit_help(cmd: &mut clap::Command, path: &str, violations: &mut Vec<(String, String)>) {
6023        let help = cmd.render_long_help().to_string();
6024        for line in scan_forbidden(&help) {
6025            violations.push((path.to_owned(), line));
6026        }
6027        let names: Vec<String> = cmd
6028            .get_subcommands()
6029            .map(|sub| sub.get_name().to_owned())
6030            .collect();
6031        for name in names {
6032            if name == "help" {
6033                continue;
6034            }
6035            if let Some(sub) = cmd.find_subcommand_mut(&name) {
6036                let sub_path = format!("{path} {name}");
6037                visit_help(sub, &sub_path, violations);
6038            }
6039        }
6040    }
6041
6042    fn scan_forbidden(s: &str) -> Vec<String> {
6043        let lower = s.to_ascii_lowercase();
6044        let mut out = Vec::new();
6045        for word in ["stub", "placeholder"] {
6046            if let Some(idx) = find_whole_word(&lower, word) {
6047                out.push(extract_line(s, idx));
6048            }
6049        }
6050        if let Some(idx) = lower.find("not yet") {
6051            out.push(extract_line(s, idx));
6052        }
6053        out
6054    }
6055
6056    fn find_whole_word(haystack: &str, word: &str) -> Option<usize> {
6057        let bytes = haystack.as_bytes();
6058        let mut start = 0;
6059        while let Some(rel) = haystack[start..].find(word) {
6060            let abs = start + rel;
6061            let before_ok = abs == 0 || !bytes[abs - 1].is_ascii_alphanumeric();
6062            let after_idx = abs + word.len();
6063            let after_ok = after_idx >= bytes.len() || !bytes[after_idx].is_ascii_alphanumeric();
6064            if before_ok && after_ok {
6065                return Some(abs);
6066            }
6067            start = abs + word.len();
6068        }
6069        None
6070    }
6071
6072    fn extract_line(s: &str, byte_idx: usize) -> String {
6073        let line_start = s[..byte_idx].rfind('\n').map_or(0, |i| i + 1);
6074        let line_end = s[byte_idx..].find('\n').map_or(s.len(), |i| byte_idx + i);
6075        s[line_start..line_end].trim().to_owned()
6076    }
6077
6078    #[test]
6079    fn emit_error_returns_given_exit_code() {
6080        let code = emit_error("test error", 2, fallow_config::OutputFormat::Human);
6081        assert_eq!(code, ExitCode::from(2));
6082    }
6083
6084    fn telemetry_run_for_mode(mode: telemetry::AnalysisMode) -> TelemetryRun {
6085        TelemetryRun {
6086            workflow: telemetry::Workflow::Health,
6087            output: fallow_config::OutputFormat::Json,
6088            quiet: true,
6089            start: std::time::Instant::now(),
6090            context: telemetry::WorkflowContext {
6091                run_scope: telemetry::RunScope::FullProject,
6092                config_shape: telemetry::ConfigShape::Default,
6093                output_destination: telemetry::OutputDestination::Stdout,
6094                analysis_mode: mode,
6095            },
6096        }
6097    }
6098
6099    #[test]
6100    fn fallback_failure_reason_skips_success_and_findings() {
6101        let run = telemetry_run_for_mode(telemetry::AnalysisMode::Static);
6102
6103        assert_eq!(fallback_failure_reason_for(&run, ExitCode::SUCCESS), None);
6104        assert_eq!(fallback_failure_reason_for(&run, ExitCode::from(1)), None);
6105    }
6106
6107    #[test]
6108    fn fallback_failure_reason_classifies_network_auth_and_analysis() {
6109        let static_run = telemetry_run_for_mode(telemetry::AnalysisMode::Static);
6110        let cloud_run = telemetry_run_for_mode(telemetry::AnalysisMode::ProductionCoverage);
6111
6112        assert_eq!(
6113            fallback_failure_reason_for(&static_run, ExitCode::from(api::NETWORK_EXIT_CODE)),
6114            Some(telemetry::FailureReason::Network),
6115        );
6116        assert_eq!(
6117            fallback_failure_reason_for(&static_run, ExitCode::from(12)),
6118            Some(telemetry::FailureReason::Auth),
6119        );
6120        assert_eq!(
6121            fallback_failure_reason_for(&cloud_run, ExitCode::from(3)),
6122            Some(telemetry::FailureReason::Auth),
6123        );
6124        assert_eq!(
6125            fallback_failure_reason_for(&static_run, ExitCode::from(2)),
6126            Some(telemetry::FailureReason::Analysis),
6127        );
6128    }
6129
6130    #[test]
6131    fn bare_coverage_flags_parse_without_subcommand() {
6132        let cli = Cli::try_parse_from([
6133            "fallow",
6134            "--coverage",
6135            "coverage/coverage-final.json",
6136            "--coverage-root",
6137            "/ci/workspace",
6138        ])
6139        .expect("bare combined coverage flags should parse");
6140        assert!(cli.command.is_none());
6141        assert_eq!(
6142            cli.coverage.as_deref(),
6143            Some(std::path::Path::new("coverage/coverage-final.json"))
6144        );
6145        assert_eq!(
6146            cli.coverage_root.as_deref(),
6147            Some(std::path::Path::new("/ci/workspace"))
6148        );
6149    }
6150
6151    #[test]
6152    fn bare_coverage_before_subcommand_is_detectable() {
6153        let cli = Cli::try_parse_from([
6154            "fallow",
6155            "--coverage",
6156            "coverage/coverage-final.json",
6157            "dead-code",
6158        ])
6159        .expect("clap should parse pre-subcommand bare coverage for custom rejection");
6160        assert!(cli.command.is_some());
6161        assert!(cli_has_bare_coverage_input(&cli));
6162        let message = bare_coverage_subcommand_error_message();
6163        assert!(message.contains("bare combined-mode flags"));
6164        assert!(message.contains("fallow health --coverage <coverage-final.json>"));
6165    }
6166
6167    #[test]
6168    fn subcommand_coverage_flag_keeps_regular_clap_error() {
6169        let Err(err) = Cli::try_parse_from(["fallow", "dead-code", "--coverage"]) else {
6170            panic!("dead-code --coverage should fail to parse");
6171        };
6172        assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
6173    }
6174
6175    #[test]
6176    fn type_aware_flags_parse_for_semantic_analysis() {
6177        let cli = Cli::try_parse_from([
6178            "fallow",
6179            "dead-code",
6180            "--unused-class-members",
6181            "--type-aware",
6182            "--type-aware-project",
6183            "tsconfig.json",
6184            "--type-aware-project",
6185            "packages/web/tsconfig.json",
6186        ])
6187        .expect("type-aware flag should parse");
6188        assert!(cli.type_aware);
6189        assert_eq!(
6190            cli.type_aware_project,
6191            [
6192                PathBuf::from("tsconfig.json"),
6193                PathBuf::from("packages/web/tsconfig.json")
6194            ]
6195        );
6196        let Some(Command::Check {
6197            unused_class_members,
6198            ..
6199        }) = cli.command
6200        else {
6201            panic!("dead-code should parse as the check command");
6202        };
6203        assert!(unused_class_members);
6204    }
6205
6206    #[test]
6207    fn no_type_aware_conflicts_with_type_aware() {
6208        let Err(err) = Cli::try_parse_from(["fallow", "audit", "--type-aware", "--no-type-aware"])
6209        else {
6210            panic!("--no-type-aware must conflict with --type-aware");
6211        };
6212        assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
6213    }
6214
6215    #[test]
6216    fn no_type_aware_forces_semantic_analysis_off() {
6217        let cli = Cli::try_parse_from(["fallow", "audit", "--no-type-aware"])
6218            .expect("--no-type-aware should parse on audit");
6219        assert_eq!(cli.type_aware_override(), Some(false));
6220
6221        let cli = Cli::try_parse_from(["fallow", "dead-code", "--type-aware"])
6222            .expect("--type-aware should parse");
6223        assert_eq!(cli.type_aware_override(), Some(true));
6224
6225        let cli = Cli::try_parse_from(["fallow", "dead-code"]).expect("bare command should parse");
6226        assert_eq!(cli.type_aware_override(), None);
6227    }
6228
6229    #[test]
6230    fn type_aware_status_output_hides_host_paths() {
6231        let root = Path::new("/private/work/project");
6232        let output = type_aware_status_output(
6233            root,
6234            fallow_api::TypeAwareStatus {
6235                available: false,
6236                discovery_source: Some("environment-override"),
6237                companion_path: Some(PathBuf::from("/private/tools/fallow-type-aware")),
6238                package_version: None,
6239                protocol_version: 6,
6240                backend_family: None,
6241                backend_version: None,
6242                remediation: Some(
6243                    "failed to launch /private/tools/fallow-type-aware from /private/work/project"
6244                        .to_string(),
6245                ),
6246            },
6247        );
6248
6249        assert_eq!(
6250            output.schema_version.0,
6251            fallow_output::TYPE_AWARE_STATUS_SCHEMA_VERSION
6252        );
6253        assert_eq!(output.companion_path.as_deref(), Some("fallow-type-aware"));
6254        let remediation = output.remediation.expect("remediation");
6255        assert!(!remediation.contains("/private/"));
6256        assert!(remediation.contains("fallow-type-aware"));
6257    }
6258
6259    #[test]
6260    fn format_parsing_covers_all_variants() {
6261        assert!(matches!(parse_format_arg("json"), Some(Format::Json)));
6262        assert!(matches!(parse_format_arg("JSON"), Some(Format::Json)));
6263        assert!(matches!(parse_format_arg("human"), Some(Format::Human)));
6264        assert!(matches!(parse_format_arg("sarif"), Some(Format::Sarif)));
6265        assert!(matches!(parse_format_arg("compact"), Some(Format::Compact)));
6266        assert!(matches!(
6267            parse_format_arg("markdown"),
6268            Some(Format::Markdown)
6269        ));
6270        assert!(matches!(parse_format_arg("md"), Some(Format::Markdown)));
6271        assert!(matches!(
6272            parse_format_arg("codeclimate"),
6273            Some(Format::CodeClimate)
6274        ));
6275        assert!(matches!(
6276            parse_format_arg("gitlab-codequality"),
6277            Some(Format::CodeClimate)
6278        ));
6279        assert!(matches!(
6280            parse_format_arg("gitlab-code-quality"),
6281            Some(Format::CodeClimate)
6282        ));
6283        assert!(matches!(
6284            parse_format_arg("pr-comment-github"),
6285            Some(Format::PrCommentGithub)
6286        ));
6287        assert!(matches!(
6288            parse_format_arg("pr-comment-gitlab"),
6289            Some(Format::PrCommentGitlab)
6290        ));
6291        assert!(matches!(
6292            parse_format_arg("review-github"),
6293            Some(Format::ReviewGithub)
6294        ));
6295        assert!(matches!(
6296            parse_format_arg("review-gitlab"),
6297            Some(Format::ReviewGitlab)
6298        ));
6299        assert!(matches!(parse_format_arg("badge"), Some(Format::Badge)));
6300        assert!(parse_format_arg("xml").is_none());
6301        assert!(parse_format_arg("").is_none());
6302    }
6303
6304    #[test]
6305    fn quiet_parsing_logic() {
6306        let parse = |s: &str| -> bool { s == "1" || s.eq_ignore_ascii_case("true") };
6307        assert!(parse("1"));
6308        assert!(parse("true"));
6309        assert!(parse("TRUE"));
6310        assert!(parse("True"));
6311        assert!(!parse("0"));
6312        assert!(!parse("false"));
6313        assert!(!parse("yes"));
6314    }
6315
6316    #[test]
6317    fn tracing_filter_defaults_to_warn_without_env() {
6318        assert_eq!(build_tracing_filter(None).to_string(), "warn");
6319    }
6320
6321    #[test]
6322    fn tracing_filter_respects_explicit_env_directives() {
6323        assert_eq!(build_tracing_filter(Some("info")).to_string(), "info");
6324    }
6325
6326    #[test]
6327    fn tracing_filter_treats_empty_env_as_off() {
6328        assert_eq!(build_tracing_filter(Some("")).to_string(), "off");
6329        assert_eq!(build_tracing_filter(Some("   ")).to_string(), "off");
6330    }
6331}