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