Skip to main content

fallow_cli/
lib.rs

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