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