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