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