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